ci: fix pipeline, update readme and anonymize ZT references

This commit is contained in:
2026-06-12 12:30:18 +02:00
commit d1bd1a9ba9
68 changed files with 10353 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* Appels réseau pour le plugin ZT.
* Toutes les fonctions fetch sont ici ; le parsing reste dans parser.ts.
*/
export async function fetchSearchResults(baseUrl: string, query: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=0&q=${encodeURIComponent(query)}&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0',
'Accept': 'text/html, */*',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl
}
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchTrendingMovies(baseUrl: string): Promise<string> {
const res = await fetch(`${baseUrl}/engine/ajax/controller.php?mod=filter&catid=3&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchTrendingSeries(baseUrl: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=15&q=&art=0&AiffchageMode=0&inputTirePar=1&cstart=0`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchContentPage(pageUrl: string): Promise<string> {
const res = await fetch(pageUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchResolvedLink(zoneursUrl: string): Promise<string> {
const url = zoneursUrl.startsWith('//') ? `https:${zoneursUrl}` : zoneursUrl;
const res = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3',
}
});
if (!res.ok) throw new Error(`HTTP ${res.status} sur ${url}`);
return res.text();
}
export async function fetchRecent(baseUrl: string): Promise<string> {
const url = `${baseUrl}/engine/ajax/controller.php?mod=filter&catid=55&q=&art=0&AiffchageMode=0&inputTirePar=0&cstart=0`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
+164
View File
@@ -0,0 +1,164 @@
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearchResults, fetchTrendingMovies, fetchTrendingSeries, fetchContentPage, fetchResolvedLink, fetchRecent } from './api.js';
import { parseSearchHTML, parseContentHTML, extractLinkFromZtProtect } from './parser.js';
/**
* Normalise un titre pour la comparaison (minuscules, sans accents, sans ponctuation).
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/[^a-z0-9]/g, '');
}
/**
* Déduplique les résultats par titre normalisé, en gardant la première occurrence.
*/
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
const seen = new Set<string>();
return results.filter(r => {
const key = normalizeTitle(r.title);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export class ZTAPI implements ISource {
name = 'zt';
displayName = 'ZT';
private baseUrl: string | undefined;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl;
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[ZT] ⚠️ ZT_URL non définie.');
return false;
}
return true;
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
if (!query || query.length < 4) throw new Error('La recherche nécessite au moins 4 caractères.');
const html = await fetchSearchResults(this.baseUrl, query);
if (html.includes('Aucun résultat')) return [];
let results = parseSearchHTML(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return deduplicateByTitle(results);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = mediaType === 'movie'
? await fetchTrendingMovies(this.baseUrl)
: await fetchTrendingSeries(this.baseUrl);
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ZT] ❌ Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchRecent(this.baseUrl);
const results = parseSearchHTML(html, this.baseUrl).slice(0, 40);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ZT] ❌ Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(pageUrl: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('ZT_URL non configurée.');
const fullUrl = pageUrl.startsWith('http') ? pageUrl : (this.baseUrl + (pageUrl.startsWith('/') ? '' : '/') + pageUrl);
const html = await fetchContentPage(fullUrl);
return parseContentHTML(html);
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const targetUrl = seasonValue ? String(seasonValue) : identifier;
const content = await this.getContentLinks(targetUrl);
const isSeries = targetUrl.includes('/telecharger-serie/') || targetUrl.includes('/serie-') || (content.relatedSeasons?.length || 0) > 0;
let currentSeasonLabel = "Saison (Actuelle)";
if (content.releaseNames && content.releaseNames.length > 0) {
const sm = content.releaseNames[0].match(/Saison\s*\d+/i);
if (sm) currentSeasonLabel = sm[0];
}
const formattedSeasons = (content.relatedSeasons || []).map(s => ({
label: s.label,
value: s.href
}));
if (isSeries) {
formattedSeasons.push({ label: currentSeasonLabel, value: targetUrl });
formattedSeasons.sort((a, b) => {
const numA = parseInt(a.label.replace(/\D/g, '')) || 0;
const numB = parseInt(b.label.replace(/\D/g, '')) || 0;
return numA - numB;
});
}
const allLinks = [...content.links];
if (content.relatedQualities && content.relatedQualities.length > 0) {
console.log(`[ZT] Fetching ${content.relatedQualities.length} other qualities concurrently...`);
const qualityPromises = content.relatedQualities.map(async (q) => {
try {
const qContent = await this.getContentLinks(q.href);
return qContent.links;
} catch (e) {
console.error(`[ZT] Error fetching quality page ${q.href}:`, e);
return [];
}
});
const otherQualitiesLinks = await Promise.all(qualityPromises);
otherQualitiesLinks.forEach(links => allLinks.push(...links));
}
return { links: allLinks, seasons: formattedSeasons, isSeries };
}
async resolveLink(linkId: string): Promise<string | null> {
try {
console.log(`[ZT] 🔓 Résolution du lien : ${linkId}`);
const html = await fetchResolvedLink(linkId);
const resolved = extractLinkFromZtProtect(html);
if (!resolved) {
console.warn(`[ZT] ⚠️ Impossible d'extraire le lien résolu du HTML de ZTProtect pour ${linkId}`);
}
return resolved;
} catch (e: any) {
console.error(`[ZT] ❌ Erreur resolveLink pour ${linkId}:`, e.message);
return null;
}
}
}
// ── Auto-registration ──
sourceRegistry.register(new ZTAPI(CONFIG.ZT_URL));
+185
View File
@@ -0,0 +1,185 @@
import { SearchResult, MediaType, ContentLinks, VideoLink } from '../../src/types/source.js';
/**
* Parse le HTML de résultats de recherche ZT.
*/
export function parseSearchHTML(html: string, baseUrl: string | undefined): SearchResult[] {
const results: SearchResult[] = [];
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
let match: RegExpExecArray | null;
while ((match = coverRegex.exec(html)) !== null) {
const block = match[1]!;
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
if (!titleMatch) continue;
const href = titleMatch[1]!.trim();
const title = titleMatch[2]!.trim();
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
let image = imgMatch ? imgMatch[1]! : null;
if (image && image.startsWith('/') && baseUrl) {
image = baseUrl + image;
}
let type: 'movie' | 'series' | 'anime' = 'movie';
if (href.includes('/telecharger-serie/') || href.includes('/serie-')) {
type = 'series';
} else if (href.includes('/animes')) {
type = 'anime';
}
results.push({ title, image, hrefPath: href, year: null, type, source: 'zt' });
}
return results;
}
/**
* Parse le HTML d'une page de contenu ZT pour en extraire les liens et saisons.
*/
export function parseContentHTML(html: string): ContentLinks {
const links: VideoLink[] = [];
const releaseNames: string[] = [];
const releaseRegex = /<font color=red>([^<]+)<\/font>/g;
let releaseMatch: RegExpExecArray | null;
while ((releaseMatch = releaseRegex.exec(html)) !== null) {
releaseNames.push(releaseMatch[1]!.trim());
}
const sections = html.split(/<img src='\/img\/([^']+)'/);
for (let i = 1; i < sections.length; i += 2) {
const hostImg = sections[i]!;
const hostName = hostImg.replace('.png', '').replace('.jpg', '').replace('.webp', '');
const sectionHtml = sections[i + 1] || '';
const linkRegex = /<a class="btnToLink"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>/g;
let linkMatch: RegExpExecArray | null;
while ((linkMatch = linkRegex.exec(sectionHtml)) !== null) {
const zoneursUrl = linkMatch[1]!;
const label = linkMatch[2]!.trim();
// Extraire la taille depuis le label : "NOM.FICHIER (11.5 GO)" → "11.5 GO"
const sizeRegex = /\s*\(([\d.,]+\s*(?:go|gb|mo|mb|ko|kb|to|tb))\)/i;
let sizeMatch = label.match(sizeRegex);
let size = sizeMatch ? sizeMatch[1]!.trim().toUpperCase() : undefined;
// Si non trouvé dans le label, on cherche dans le nom de la release (qualité)
if (!size && releaseNames.length > 0) {
const qualityMatch = releaseNames[0].match(sizeRegex);
if (qualityMatch) size = qualityMatch[1]!.trim().toUpperCase();
}
// Nettoyer le label pour enlever la taille
const cleanedLabel = label.replace(sizeRegex, "").trim();
// On n'utilise le label comme "épisode" que si c'est un vrai nom de fichier/épisode (pas juste "Télécharger")
const isGenericLabel = /^(t\u00e9l\u00e9charger|download|cliquez ici|lien|turbobit|1fichier|uptobox|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
let episode = (!isGenericLabel && cleanedLabel.length > 3) ? cleanedLabel : undefined;
// SI le label est générique, on cherche un texte juste avant (ex: "Episode 1")
if (isGenericLabel || !episode) {
const index = linkMatch.index;
const prevHtml = sectionHtml.substring(Math.max(0, index - 100), index);
// Cherche "Episode X", "Saison complète", etc.
const epMatch = prevHtml.match(/(?:<b>|<strong>)?(Episode\s*\d+|Saison\s*compl\u00e8te)(?:<\/b>|<\/strong>)?/i);
if (epMatch) {
episode = epMatch[1].trim();
}
}
let quality = releaseNames.length > 0 ? releaseNames[0] : 'Inconnu';
if (quality.match(sizeRegex)) quality = quality.replace(sizeRegex, '');
let langs: string[] = [];
let subs: string[] = [];
const textToScan = `${quality} ${cleanedLabel}`;
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
if (langMatch) {
const seenLangs = new Set<string>();
const seenSubs = new Set<string>();
langMatch.forEach(l => {
const up = l.toUpperCase();
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
});
langs = Array.from(seenLangs);
subs = Array.from(seenSubs);
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
}
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
links.push({
id: zoneursUrl,
host: hostName,
label: cleanedLabel,
url: null,
size,
quality: quality,
langs,
subs,
episode: episode,
});
}
}
const relatedSeasons: { href: string; label: string }[] = [];
const relatedQualities: { href: string; label: string }[] = [];
// Chercher toutes les sections "également disponibles"
const sectionRegex = /(Saisons?|Qualit(?:é|e)s?)\s*également disponibles[\s\S]*?<\/h3>([\s\S]*?)(?:<h3|<\/div>|<div[^>]*class="postinfo")/gi;
let sSectionMatch: RegExpExecArray | null;
while ((sSectionMatch = sectionRegex.exec(html)) !== null) {
const type = sSectionMatch[1].toLowerCase();
const seasonBlock = sSectionMatch[2]!;
const seasonRegex = /<a[^>]*href="([^"]+)"[^>]*><span class="otherquality">([\s\S]*?)<\/span><\/a>/g;
let sMatch: RegExpExecArray | null;
while ((sMatch = seasonRegex.exec(seasonBlock)) !== null) {
const label = sMatch[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const href = sMatch[1]!.trim();
if (type.includes('saison')) {
if (!relatedSeasons.find(rs => rs.href === href)) {
relatedSeasons.push({ href, label });
}
} else {
if (!relatedQualities.find(rs => rs.href === href)) {
relatedQualities.push({ href, label });
}
}
}
}
return { links, releaseNames, relatedSeasons, relatedQualities };
}
/**
* Extrait le lien final déverrouillé de la page HTML de ZTPROTECT.
*/
export function extractLinkFromZtProtect(html: string): string | null {
// 1. Essayer de trouver la valeur de l'input result-input
let match = html.match(/class="result-input"\s+value="([^"]+)"/i);
if (match && match[1]) return match[1];
// 2. Essayer de trouver l'attribut href du bouton de succès
match = html.match(/<a\s+[^>]*href="([^"]+)"[^>]*class="[^"]*btn-success[^"]*"/i);
if (match && match[1]) return match[1];
match = html.match(/class="[^"]*btn-success[^"]*"\s+[^>]*href="([^"]+)"/i);
if (match && match[1]) return match[1];
return null;
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Appels réseau pour free-telecharger.cam.
* Pas de challenge CF actif, fetch direct simple.
*/
const TIMEOUT = 20_000;
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
async function ftGet(url: string): Promise<string> {
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
},
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
return ftGet(`${baseUrl}/1/recherche1/1.html?rech_fiche=${encodeURIComponent(query)}`);
}
export async function fetchTrending(baseUrl: string): Promise<string> {
return ftGet(`${baseUrl}/page/1.html`);
}
export async function fetchPage(pageUrl: string): Promise<string> {
return ftGet(pageUrl);
}
+142
View File
@@ -0,0 +1,142 @@
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
import { parseSearchResults, parseTrendingResults, parseContentHTML, parseEpisodeLinks, parseOtherVersions } from './parser.js';
function isSeriesIdentifier(identifier: string): boolean {
return /saison|pack-series|series-(vf|vostfr|terminee)/i.test(identifier);
}
export class FreeTeleAPI implements ISource {
name = 'freetel';
displayName = 'Free-Télécharger';
private baseUrl: string | undefined;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl?.replace(/\/$/, '');
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[FreeTel] ⚠️ FT_URL non définie.');
return false;
}
try {
const res = await fetch(this.baseUrl, {
method: 'HEAD',
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(5000),
});
return res.ok;
} catch {
return true; // tolérant : le test réel se fait au premier scrape
}
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
const html = await fetchSearch(this.baseUrl, query);
let results = parseSearchResults(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return results;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl);
let results = parseTrendingResults(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return results.slice(0, 20);
} catch (e: any) {
console.error(`[FreeTel] Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl);
const results = parseTrendingResults(html, this.baseUrl).slice(0, 20);
return results;
} catch (e: any) {
console.error(`[FreeTel] Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(identifier: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
return parseContentHTML(html, isSeriesIdentifier(identifier));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.baseUrl) throw new Error('FT_URL non configurée.');
// Si seasonValue est fournie (l'UI a cliqué sur une autre qualité), on switch de fiche
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
const isSeries = isSeriesIdentifier(targetIdentifier);
const content = parseContentHTML(html, isSeries);
// Pour les films, exposer les autres qualités comme "seasons" (l'UI les affichera en dropdown)
let seasons: { label: string; value: string }[] = [];
if (!isSeries) {
seasons = parseOtherVersions(html, this.baseUrl);
// Ajouter la version courante comme première entrée (sélectionnée par défaut)
const currentQuality = content.links[0]?.quality;
if (currentQuality && currentQuality !== 'Inconnu') {
seasons.unshift({ label: currentQuality, value: targetIdentifier });
}
}
return {
links: content.links,
seasons,
isSeries,
};
}
async resolveLink(linkId: string): Promise<string | null> {
let hostUrl: string | null = null;
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
if (linkId.includes('liens.free-telecharger.cam')) {
try {
const html = await fetchPage(linkId);
const hosts = parseEpisodeLinks(html);
if (hosts.length === 0) {
console.warn(`[FreeTel] Aucun hôte trouvé sur ${linkId}`);
return null;
}
const preferred = hosts.find(h => /1fichier/i.test(h.host))
|| hosts.find(h => /turbobit/i.test(h.host))
|| hosts[0];
hostUrl = preferred ? preferred.url : null;
} catch (e: any) {
console.error(`[FreeTel] Erreur resolveLink:`, e.message);
return null;
}
} else if (linkId.startsWith('http')) {
// Cas film : linkId est déjà l'URL hôte (1fichier, Turbobit, …)
hostUrl = linkId;
}
return hostUrl;
}
}
sourceRegistry.register(new FreeTeleAPI(CONFIG.FT_URL));
+193
View File
@@ -0,0 +1,193 @@
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
interface FilmMetadata {
quality?: string;
size?: string;
langs?: string[];
}
function parseFilmMetadata(html: string): FilmMetadata {
const meta: FilmMetadata = {};
const q = html.match(/Qualit[ée][^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (q) meta.quality = q[1]!.trim();
const t = html.match(/Taille[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (t) meta.size = t[1]!.trim();
const l = html.match(/Langue[^:]*:\s*<\/b>\s*([^<\n]+?)\s*<br/i);
if (l) meta.langs = l[1]!.trim().split(/[,\/]/).map(s => s.trim()).filter(Boolean);
return meta;
}
/**
* Extrait les autres versions/qualités disponibles pour le même film.
* Section "Autres versions disponibles pour ..."
*/
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
const out: { label: string; value: string }[] = [];
const sectionMatch = html.match(/Autres versions disponibles[\s\S]+?<\/div>\s*<\/div>/i);
if (!sectionMatch) return out;
const linkRegex = /<a\s+href="([^"]+)"[\s\S]*?🎞️\s*([^<]+?)<\/a>/gi;
let m: RegExpExecArray | null;
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
const href = absUrl(m[1]!, baseUrl);
const label = m[2]!.replace(/\s+/g, ' ').trim();
if (!out.find(o => o.value === href)) out.push({ label, value: href });
}
return out;
}
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/\b(web-?dl|web-?rip|blu-?ray|full-?blu-?ray|hdtv|hdrip|dvdrip|bdrip|hdlight|ultra-?hdlight|truefrench|french|multi(?:langues?)?|vff|vfq|vfi|vf|vostfr|english|hdts|cam|ts|r5|dvdscr|x264|x265|h\.?264|h\.?265|hevc)\b/g, '')
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(it => {
const k = normalizeTitle(it.title);
if (!k || seen.has(k)) return false;
seen.add(k);
return true;
});
}
function detectType(href: string): 'movie' | 'series' | 'anime' {
if (/saison|pack-series|series-(vf|vostfr|terminee)/i.test(href)) return 'series';
if (/animes?/i.test(href)) return 'anime';
return 'movie';
}
function absUrl(url: string, baseUrl: string): string {
if (url.startsWith('http')) return url;
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + '/' + url.replace(/^\//, '');
}
/**
* Format résultats de recherche : <div class="image-container"><img/></div>
* puis <div class="content"><div class="titre1"><A href="...">Titre</A></div>
*/
export function parseSearchResults(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const blockRegex = /<div\s+class="image-container">\s*<img[^>]+src="([^"]+)"[^>]*>[\s\S]*?<div\s+class="titre1">\s*<a\s+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
let m: RegExpExecArray | null;
while ((m = blockRegex.exec(html)) !== null) {
const image = absUrl(m[1]!, baseUrl);
const hrefRaw = m[2]!;
const href = absUrl(hrefRaw, baseUrl);
const title = m[3]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (!title) continue;
results.push({
title,
year: null,
image,
hrefPath: href,
type: detectType(hrefRaw),
source: 'freetel',
});
}
return deduplicateByTitle(results);
}
/**
* Format nouveautés (/page/1.html) : <a href="..." data-tip-b64="..."><img alt="Titre" src="..."/></a>
*/
export function parseTrendingResults(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const blockRegex = /<a\s+href="((?:films?-|saison-|pack-series|series-)[^"]+\.html)"[^>]*data-tip-b64="[^"]+"[^>]*>\s*<img\s+alt="([^"]+)"[^>]+src="([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = blockRegex.exec(html)) !== null) {
const hrefRaw = m[1]!;
const title = m[2]!.trim();
const image = absUrl(m[3]!, baseUrl);
results.push({
title,
year: null,
image,
hrefPath: absUrl(hrefRaw, baseUrl),
type: detectType(hrefRaw),
source: 'freetel',
});
}
return deduplicateByTitle(results);
}
/**
* Parse une fiche (film ou série).
* - Film : <input name="lien" value="https://turbobit.net/..."> dans la section #link, précédé d'un <p>HOST</p>
* - Série : <input name="lien" value="https://liens.free-telecharger.cam/SLUG-episode_N"> (à résoudre via resolveLink)
*/
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
const links: VideoLink[] = [];
if (isSeries) {
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.cam\/[^"]+)"/gi;
let m: RegExpExecArray | null;
let idx = 0;
while ((m = episodeRegex.exec(html)) !== null) {
const url = m[1]!;
const epMatch = url.match(/episode_(\d+|final|complet)/i);
const episode = epMatch ? epMatch[1] : null;
links.push({
id: url,
host: 'multi',
label: episode ? `Épisode ${episode}` : `Lien ${idx + 1}`,
episode: episode || undefined,
quality: 'multi',
url: null,
});
idx++;
}
} else {
// Films : section #link contient des blocs (Host name dans <p>, URL dans <input hidden lien>)
const meta = parseFilmMetadata(html);
const sectionMatch = html.match(/<div\s+id="link"[\s\S]+/);
const sec = sectionMatch ? sectionMatch[0] : html;
const pairRegex = /<p[^>]*>\s*([A-Za-z0-9-]+)\s*<\/p>[\s\S]{0,800}?<input[^>]+name="lien"\s+value="([^"]+)"/gi;
let m: RegExpExecArray | null;
while ((m = pairRegex.exec(sec)) !== null) {
const host = m[1]!.trim();
const url = m[2]!;
if (/free-telecharger|trustzone|get-trust-zone/i.test(url)) continue;
links.push({
id: url,
host: host.toLowerCase(),
label: host,
quality: meta.quality || 'Inconnu',
size: meta.size,
langs: meta.langs,
url: url,
});
}
}
return { links };
}
/**
* Parse la page intermédiaire d'un épisode (liens.free-telecharger.cam/...).
* Structure : <table class="gridtable"> avec <tr> contenant [HOST] et <a href="URL">.
*/
export function parseEpisodeLinks(html: string): { host: string; url: string }[] {
const out: { host: string; url: string }[] = [];
const tableMatch = html.match(/<table[^>]*class="gridtable"[\s\S]*?<\/table>/i);
if (!tableMatch) return out;
const rows = tableMatch[0].match(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const row of rows) {
const hostMatch = row.match(/\[([^\]]+)\]/);
const aMatch = row.match(/<a\s+[^>]*href\s*=\s*["']?([^"'\s>]+)/i);
if (hostMatch && aMatch) {
out.push({
host: hostMatch[1]!.toLowerCase().trim(),
url: aMatch[1]!.trim(),
});
}
}
return out;
}
+143
View File
@@ -0,0 +1,143 @@
import { CONFIG } from '../../src/utils/config.js';
export const CONFIG_HYDRACKER = {
BASE_URL: (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''), // Supprime le slash final
API_KEY: CONFIG.HYDRACKER_API_KEY,
TIMEOUT: CONFIG.HYDRACKER_TIMEOUT || 15000,
};
const HYDRACKER_HEADERS = {
'Accept': 'application/json',
'Authorization': `Bearer ${CONFIG_HYDRACKER.API_KEY}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
};
const TIMEOUT = CONFIG_HYDRACKER.TIMEOUT; // 30 secondes par défaut (configurable)
async function fetchWithRetry(
url: string,
options: RequestInit = {},
maxRetries: number = 2,
initialDelay: number = 2000
): Promise<Response> {
let attempt = 0;
let delay = initialDelay;
while (true) {
attempt++;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
try {
const res = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.status === 502 || res.status === 503 || res.status === 504 || res.status === 429) {
if (attempt < maxRetries) {
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} returned HTTP ${res.status} on fetch. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
continue;
}
}
return res;
} catch (err: any) {
clearTimeout(timeoutId);
const isTimeout = err.name === 'AbortError' || err.message?.includes('aborted');
if (attempt < maxRetries) {
const waitTime = isTimeout ? 1000 : delay;
console.warn(`[Hydracker-API] Attempt ${attempt}/${maxRetries} failed/timed out (${err.message}). Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
if (!isTimeout) delay *= 2;
continue;
}
throw err;
}
}
}
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}` + (qs ? `?${qs}` : '');
try {
const res = await fetchWithRetry(url, {
headers: HYDRACKER_HEADERS
});
if (!res.ok) {
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
return null;
}
return await res.json();
} catch (e: any) {
console.error(`[Hydracker-API] apiGet Error on ${urlPath}:`, e.message);
return null;
}
}
export async function apiPost(urlPath: string, body: any = {}) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}`;
try {
const res = await fetchWithRetry(url, {
method: 'POST',
headers: { ...HYDRACKER_HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, body: await res.text() };
} catch (e: any) {
console.error(`[Hydracker-API] apiPost Error on ${urlPath}:`, e.message);
return null;
}
}
export async function fetchSearch(query: string) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/search/${encodeURIComponent(query)}?loader=searchAutocomplete`;
try {
const res = await fetchWithRetry(url, {
headers: HYDRACKER_HEADERS
});
if (!res.ok) {
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
return null;
}
return await res.json();
} catch (e: any) {
console.error('[Hydracker-API] Search failed:', e.message);
return null;
}
}
export async function fetchMovieLinks(titleId: string) {
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
try {
const res = await fetchWithRetry(url, {
headers: HYDRACKER_HEADERS
});
if (!res.ok) return null;
return await res.json();
} catch (e: any) {
return null;
}
}
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
const allLiens: any[] = [];
let page = 1;
while (true) {
const result = await apiGet('liens', {
title_id: titleId, loader: 'linksdl', season,
perPage: 500, page, filters: '', paginate: 'lengthAware'
});
if (!result || result.error) break;
const pagination = result.pagination || {};
const data = pagination.data || [];
if (!data.length) break;
allLiens.push(...data);
const lastPage = pagination.last_page || pagination.lastPage || 1;
if (page >= lastPage) break;
page++;
}
return allLiens;
}
+225
View File
@@ -0,0 +1,225 @@
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchMovieLinks, fetchSeriesLiens } from './api.js';
import {
QUALITY_MAP, formatSize,
parseSearchResults, parseTrendingResults,
parseMovieLinks, parseSeasons, parsePremiumLink,
getLangs, getSubs
} from './parser.js';
export class HydrackerAPI implements ISource {
name = 'hydracker';
displayName = 'Hydracker (Token)';
async healthCheck(): Promise<boolean> {
if (!CONFIG_HYDRACKER.BASE_URL || !CONFIG_HYDRACKER.API_KEY) {
console.warn('[Hydracker] ⚠️ HYDRACKER_URL ou HYDRACKER_API_KEY manquante.');
return false;
}
try {
const res = await fetch(CONFIG_HYDRACKER.BASE_URL, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' },
signal: AbortSignal.timeout(CONFIG_HYDRACKER.TIMEOUT)
});
return res.ok;
} catch {
return false;
}
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
const data = await fetchSearch(query);
if (!data) {
console.error('[Hydracker] search: fetchSearch a retourné null pour', query);
return [];
}
const totalRaw = (data.results || []).length;
const parsed = parseSearchResults(data, mediaType);
console.log(`[Hydracker] search "${query}" (${mediaType}): ${totalRaw} résultats bruts → ${parsed.length} après filtre`);
return parsed;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const type = mediaType === 'series' ? 'series' : 'movie';
try {
const data = await apiGet('titles', { order: 'trending:desc', type, page: 1, paginate: 'lengthAware' });
return parseTrendingResults(data);
} catch (e: any) {
console.error(`[Hydracker] getTrending Error for ${type}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
try {
const data = await apiGet('titles', { order: 'created_at:desc', page: 1, paginate: 'lengthAware' });
return parseTrendingResults(data);
} catch (e: any) {
console.error(`[Hydracker] getRecent Error:`, e.message);
return [];
}
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const seasonsList = await this.getSeasons(identifier);
let isSeries = false;
if (type) {
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
} else {
isSeries = seasonsList.length > 0;
}
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : 1;
const content = await this.getContentLinks(identifier, currentSeason);
const formattedSeasons = seasonsList.map(num => ({ label: `Saison ${num}`, value: num }));
return {
links: content.links,
seasons: isSeries ? formattedSeasons : [],
isSeries
};
}
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
// Essai film en premier
const movieData = await fetchMovieLinks(titleId);
if (movieData) {
const movieLinks = parseMovieLinks(movieData);
if (movieLinks.length > 0) return { links: movieLinks };
}
// Fallback série
const rawLiens = await fetchSeriesLiens(titleId, season);
const links: VideoLink[] = rawLiens.map(l => ({
id: l.id,
host: (l.host && l.host.name) || '?',
size: formatSize(l.taille),
sizeBytes: l.taille || 0,
quality: QUALITY_MAP[l.qualite] || `id:${l.qualite}`,
langs: getLangs(l),
subs: getSubs(l),
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00")
? 'Saison complète'
: (l.episode ? String(l.episode) : null),
url: null
}));
return { links };
}
async getSeasons(titleId: string): Promise<number[]> {
const result = await apiGet(`titles/${titleId}/seasons`);
return parseSeasons(result);
}
private isPremiumCache: boolean | null = null;
private premiumCheckPromise: Promise<boolean> | null = null;
async checkPremiumStatus(): Promise<boolean> {
if (this.isPremiumCache !== null) return this.isPremiumCache;
if (this.premiumCheckPromise) return this.premiumCheckPromise;
this.premiumCheckPromise = (async () => {
try {
const result = await apiGet('users/me');
if (result && result.user) {
this.isPremiumCache = !!result.user.IsPremium;
console.log(`[Hydracker] Statut Premium vérifié: ${this.isPremiumCache ? 'OUI' : 'NON'}`);
return this.isPremiumCache;
}
} catch (e: any) {
console.error('[Hydracker] Erreur vérification Premium:', e.message);
}
return false;
})();
return await this.premiumCheckPromise;
}
async resolveLink(linkId: string): Promise<string | null> {
// Tentative de résolution via la base locale d'abord
const localDbSource = sourceRegistry.get('localdb') as any;
if (localDbSource && typeof localDbSource.resolveLocalLink === 'function') {
const localUrl = localDbSource.resolveLocalLink(linkId);
if (localUrl) {
console.log(`[Hydracker] Lien résolu via base de données locale (ID: ${linkId})`);
return localUrl;
}
}
const isPremium = await this.checkPremiumStatus();
if (!isPremium) {
console.log(`[Hydracker] Compte non Premium détecté. Bypass de Hydracker, passage direct à Movix...`);
return await this.resolveMovixLink(linkId);
}
const maxRetries = 4;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (attempt > 1) {
console.log(`[Hydracker] Retry ${attempt}/${maxRetries} for lien ${linkId}`);
await new Promise(r => setTimeout(r, 4000));
}
const result = await apiGet(`content/liens/${linkId}`);
if (!result) continue;
const finalUrl = result.directDL || result.url || result.link || '';
if (!finalUrl) continue;
console.log(`[Hydracker] Got final URL: ${finalUrl.substring(0, 80)}...`);
return finalUrl;
} catch (e: any) {
console.error(`[Hydracker] Exception resolving lien ${linkId} (attempt ${attempt}):`, e.message);
}
}
console.log(`[Hydracker] Échec de la résolution classique (Erreur). Fallback automatique via Movix...`);
return await this.resolveMovixLink(linkId);
}
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
try {
console.log(`[Hydracker] Tentative de débridage Movix pour le lien ${lienId}...`);
const url = `https://api.movix.cloud/api/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Referer': 'https://movix.cloud/',
'Origin': 'https://movix.cloud',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
});
const data = await response.json();
if (!response.ok || data.success === false) {
console.error('[Hydracker] Erreur API Movix:', data.error || 'Erreur inconnue');
return null;
}
// Récupération du lien direct selon le format de réponse Movix
const directUrl = data.directDL || data.direct_url ||
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
if (directUrl) {
console.log(`[Hydracker] Movix a résolu le lien avec succès !`);
return directUrl;
}
return null;
} catch (e: any) {
console.error(`[Hydracker] Exception lors de la résolution Movix :`, e.message);
return null;
}
}
}
// ── Auto-registration ──
sourceRegistry.register(new HydrackerAPI());
+167
View File
@@ -0,0 +1,167 @@
import { SearchResult, MediaType, VideoLink } from '../../src/types/source.js';
export const QUALITY_MAP: Record<number, string> = {
89: "REMUX UHD", 57: "REMUX BLURAY", 92: "REMUX DVD",
17: "Blu-Ray 1080p", 76: "Blu-Ray 1080p (x265)", 16: "Blu-Ray 720p", 18: "Blu-Ray 3D",
52: "HD 1080p", 31: "HD 720p",
50: "HDLight 1080p", 86: "HDLight 1080p (x265)", 49: "HDLight 720p",
60: "Ultra HDLight (x265)", 53: "ULTRA HD (x265)",
55: "WEB 1080p", 83: "WEB 1080p (x265)", 94: "WEB 1080p Light", 54: "WEB 720p", 4: "WEB",
62: "HDTV 1080p", 61: "HDTV 720p", 14: "HDTV",
15: "HDRip", 1: "DVDRIP", 51: "DVDRIP MKV",
13: "ISO", 12: "IMG", 10: "DVD-R", 11: "Full-DVD",
};
export const LANGUAGE_MAP: Record<number, string> = {
1: "MULTI", 2: "Arab", 3: "Bengali", 4: "Chinese", 5: "English", 6: "French", 7: "French (Canada)",
8: "TrueFrench", 9: "German", 10: "Hindi", 11: "Italian", 12: "Japanese", 13: "Korean",
14: "Mandarin", 15: "Portuguese", 16: "Russian", 17: "Spanish", 18: "Turkish", 19: "unknown",
23: "Danish", 28: "Finnish", 33: "Swedish", 35: "Bulgarian", 40: "Dutch", 41: "Persian",
42: "Indonesian", 43: "Hebrew", 44: "Thai", 49: "Czech", 53: "Albanian", 57: "Greek",
61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish", 71: "Lithuanian",
78: "Croatian", 84: "Malay", 90: "Romanian", 96: "Ukrainian", 102: "Vietnamese",
105: "Sámegiella", 106: "Muet", 108: "Georgian", 110: "Nigerian", 113: "Maasai",
117: "Estonian", 120: "Serbian", 123: "Slovak", 124: "Slovenian", 125: "Amharic",
126: "Belarusian", 127: "Bosnian", 128: "Burmese", 129: "Dzongkha", 137: "Icelandic",
138: "Kazakh", 139: "Kurdish", 140: "Latin", 141: "Latvian", 142: "Macedonian", 143: "Maori",
144: "Mongolian", 145: "Norwegian Bokmål", 146: "Serbo-Croatian", 148: "Tagalog", 149: "Tibetan",
150: "Walloon", 151: "Wolof", 152: "Yoruba", 154: "Moore", 155: "Quechuan", 156: "Rwanda",
160: "Filipino", 161: "VO", 165: "Afrikaans", 171: "Créole", 174: "Gujarati", 175: "Cantonese",
177: "FRENCH AD"
};
export const SUB_MAP: Record<number, string> = {
1: "Arab", 2: "Bengali", 3: "Chinese", 4: "English", 5: "French", 6: "German", 7: "Hindi",
8: "Italian", 9: "Japanese", 10: "Korean", 11: "Mandarin", 12: "Portuguese", 13: "Russian",
14: "Spanish", 15: "Turkish", 16: "Inconnu", 17: "Multi", 23: "Danish", 28: "Finnish",
33: "Swedish", 35: "Bulgare", 36: "Persian", 37: "Hebrew", 40: "Dutch", 42: "Indonesian",
50: "Thai", 53: "Greek", 61: "Hungarian", 65: "Malaysian", 66: "Norwegian", 68: "Polish",
71: "Lithuanian", 76: "Czech", 82: "Croatian", 88: "Malay", 94: "Romanian", 100: "Ukrainian",
106: "Vietnamese", 112: "Sámegiella", 115: "Estonian", 120: "Serbian", 123: "Slovak",
127: "Slovenian", 128: "Afrikaans", 129: "Albanian", 130: "Amharic", 131: "Armenian",
132: "Azerbaijani", 133: "Basque", 134: "Belarusian", 135: "Bosnian", 136: "Catalan",
137: "Cebuano", 138: "Chichewa", 139: "Corsican", 140: "Esperanto", 141: "Frisian",
142: "Galician", 143: "Georgian", 144: "Gujarati", 145: "Haitian Creole", 146: "Hausa",
147: "Hawaiian", 148: "Icelandic", 149: "Igbo", 150: "Irish", 151: "Javanese", 152: "Kannada",
153: "Kazakh", 154: "Khmer", 155: "Kurdish", 156: "Kyrgyz", 157: "Lao", 158: "Latin",
159: "Latvian", 160: "Luxembourgish", 161: "Macedonian", 162: "Malagasy", 163: "Maltese",
164: "Maori", 165: "Marathi", 166: "Mongolian", 167: "Myanmar", 168: "Nepali", 169: "Pashto",
170: "Punjabi", 171: "Sindhi", 172: "Sinhala", 173: "Somali", 174: "Swahili", 175: "Tajik",
176: "Tamil", 177: "Telugu", 178: "Uzbek", 179: "Welsh", 180: "Xhosa", 181: "Yiddish",
182: "Yoruba", 183: "Zulu", 184: "Filipino"
};
export function getLangs(l: any): string[] {
if (l.langues_compact && l.langues_compact.length) {
return l.langues_compact.map((la: any) => la.name || '');
}
if (l.langues && Array.isArray(l.langues)) {
return l.langues.map((id: any) => LANGUAGE_MAP[id] || '');
}
return [];
}
export function getSubs(l: any): string[] {
if (l.subs_compact && l.subs_compact.length) {
return l.subs_compact.map((la: any) => la.name || '');
}
if (l.subs && Array.isArray(l.subs)) {
return l.subs.map((id: any) => SUB_MAP[id] || '');
}
return [];
}
export function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return 'N/A';
const gb = bytes / (1024 ** 3);
if (gb >= 1) return `${gb.toFixed(2)} Go`;
const mb = bytes / (1024 ** 2);
return `${mb.toFixed(0)} Mo`;
}
export function parseSearchResults(data: any, mediaType: MediaType): SearchResult[] {
const EXCLUDED_TYPES = ['games', 'music', 'app', 'ebook', 'emulation'];
// Accepte à la fois les entrées avec model_type === 'title' et celles sans ce champ
const results = (data.results || []).filter((r: any) =>
(!r.model_type || r.model_type === 'title') &&
!EXCLUDED_TYPES.includes((r.type || '').toLowerCase())
);
const filtered = results.filter((r: any) => {
const rType = (r.type || (r.is_series ? 'series' : 'movie')).toLowerCase();
if (mediaType === 'movie') {
return rType === 'movie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
}
// Pour les séries
return rType === 'series' || rType === 'serie' || rType === 'animes' || rType === 'anime' || rType === 'doc' || rType === 'other';
});
return filtered.map((r: any) => ({
title: r.name,
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
image: r.poster || r.image || null,
hrefPath: String(r.id),
type: r.type || (r.is_series ? 'series' : 'movie'),
source: 'hydracker',
hydrackerId: String(r.id)
}));
}
export function parseTrendingResults(data: any): SearchResult[] {
if (!data) return [];
const results = (data.pagination || {}).data || data.data || [];
return results.map((r: any) => ({
title: r.name,
year: r.year || (r.release_date ? r.release_date.substring(0, 4) : 'N/A'),
image: r.poster || r.image || null,
hrefPath: String(r.id),
type: r.type || (r.is_series ? 'series' : 'movie'),
source: 'hydracker',
hydrackerId: String(r.id)
})).slice(0, 19);
}
export function parseMovieLinks(data: any): VideoLink[] {
const all: any[] = [];
if (data.video) all.push(data.video);
if (Array.isArray(data.alternative_videos)) all.push(...data.alternative_videos);
return all.filter(l => l.lien).map(l => ({
id: l.id,
host: (l.host && l.host.name) ? l.host.name : 'Inconnu',
url: l.lien || data.directDL,
size: formatSize(l.taille),
sizeBytes: l.taille || 0,
quality: l.quality || QUALITY_MAP[l.qualite] || 'Inconnu',
langs: getLangs(l),
subs: getSubs(l),
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
}));
}
export function parseSeasons(result: any): number[] {
if (result && !result.error) {
const seasons = result.seasons || (result.pagination || {}).data || [];
if (Array.isArray(seasons) && seasons.length) {
return seasons
.map((s: any) => typeof s === 'object' ? (s.number || s) : s)
.filter((n: any) => typeof n === 'number' && n > 0)
.sort((a: number, b: number) => a - b);
}
}
return [];
}
export function parsePremiumLink(body: string): string | null {
let data;
try { data = JSON.parse(body); } catch { return null; }
let lienData = null;
if (data.liens && Array.isArray(data.liens) && data.liens.length > 0) {
lienData = data.liens[0];
} else {
lienData = data.lien || data;
}
return lienData.lien || lienData.url || lienData.link || null;
}
+563
View File
@@ -0,0 +1,563 @@
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData, VideoLink } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
type IndexedTitle = {
norm: string;
normOrig: string;
// Distinct token list for the entry (union of norm + normOrig words).
// Precomputed at index build time so the search hot path never
// re-splits/dedupes these strings.
words: string[];
title_name: string;
original_title: string | null;
tmdb_id: number;
category_name: string;
title_poster: string | null;
created_at: string | null;
};
export class LocalDatabaseAPI implements ISource {
name = 'localdb';
displayName = 'Base de données locale';
private db: any = null;
private dbPath: string;
private titleIndex: IndexedTitle[] | null = null;
// Inverted indexes used by search() to shrink the candidate set from
// ~104K rows down to <2K before running tier scoring. Populated by
// buildTitleIndex(); never read or written outside of that method
// and search().
private tokenIndex: Map<string, number[]> | null = null; // exact token -> row indices
private titleByNorm: Map<string, number[]> | null = null; // full norm -> row indices (Tier 1)
private prefixIndex: Map<string, number[]> | null = null; // 2-char prefix-> row indices (Tier 2 + fuzzy)
constructor() {
this.dbPath = path.resolve(CONFIG.DB_PATH || './database/darkiworld.db');
}
private initDb(): boolean {
if (this.db) return true;
if (!fs.existsSync(this.dbPath)) {
return false;
}
try {
// readOnly avoids journal/WAL writes (plugin only reads).
this.db = new DatabaseSync(this.dbPath, { readOnly: true });
// Keep SQLite's temp store in RAM so big GROUP BY / sort
// operations don't spill to /tmp (a small tmpfs in the
// hardened container). Also bump page cache + mmap for
// the initial index scan.
for (const p of [
'PRAGMA temp_store = MEMORY',
'PRAGMA cache_size = -8000', // ~8MB page cache
'PRAGMA mmap_size = 67108864', // 64MB mmap, not 256MB
]) {
this.db.prepare(p).run();
}
return true;
} catch (e: any) {
console.error('[LocalDB] ❌ Erreur lors de l\'ouverture de la base SQLite native:', e.message);
return false;
}
}
async healthCheck(): Promise<boolean> {
const ok = this.initDb();
if (ok) {
// Warm the search indexes right after registration so the
// first /search request doesn't eat the multi-second build
// cost. setImmediate yields the current tick — the parallel
// health checks of other plugins still run first.
setImmediate(() => {
try { this.buildTitleIndex(); }
catch (e: any) { console.error('[LocalDB] Index warmup failed:', e.message); }
});
}
return ok;
}
// Lowercase, strip diacritics, strip apostrophes, collapse to alnum tokens.
// "Pokémon: l'aventure" -> "pokemon l aventure"
private static normalize(s: string | null | undefined): string {
if (!s) return '';
return s
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/['"`’ʼ]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
// Bounded Levenshtein. Returns max+1 if it would exceed `max` (cheap exit).
private static editDistance(a: string, b: string, max: number): number {
const la = a.length, lb = b.length;
if (Math.abs(la - lb) > max) return max + 1;
if (la === 0) return lb;
if (lb === 0) return la;
let prev = new Array(lb + 1);
let curr = new Array(lb + 1);
for (let j = 0; j <= lb; j++) prev[j] = j;
for (let i = 1; i <= la; i++) {
curr[0] = i;
let rowMin = curr[0];
const ai = a.charCodeAt(i - 1);
for (let j = 1; j <= lb; j++) {
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
const v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
curr[j] = v;
if (v < rowMin) rowMin = v;
}
if (rowMin > max) return max + 1;
const tmp = prev; prev = curr; curr = tmp;
}
return prev[lb];
}
// Per-token edit-distance budget. Short words must match almost exactly;
// longer words tolerate more typos.
private static fuzzyBudget(tok: string): number {
if (tok.length <= 3) return 0;
if (tok.length <= 5) return 1;
if (tok.length <= 8) return 2;
return 3;
}
private buildTitleIndex(): void {
if (this.titleIndex !== null) return;
if (!this.initDb()) {
this.titleIndex = [];
this.tokenIndex = new Map();
this.titleByNorm = new Map();
this.prefixIndex = new Map();
return;
}
const t0 = Date.now();
const sql = `
SELECT title_name,
original_title,
tmdb_id,
category_name,
title_poster,
MIN(created_at) AS created_at
FROM links_small
GROUP BY title_name, tmdb_id
`;
const rows = this.db.prepare(sql).all() as any[];
const titleIndex = new Array<IndexedTitle>(rows.length);
const tokenIndex = new Map<string, number[]>();
const titleByNorm = new Map<string, number[]>();
const prefixIndex = new Map<string, number[]>();
const push = (m: Map<string, number[]>, key: string, idx: number) => {
const list = m.get(key);
if (list) list.push(idx);
else m.set(key, [idx]);
};
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const norm = LocalDatabaseAPI.normalize(r.title_name);
const normOrig = LocalDatabaseAPI.normalize(r.original_title);
// Deduplicated union of words from both title fields.
const seen = new Set<string>();
const words: string[] = [];
if (norm) for (const w of norm.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
if (normOrig) for (const w of normOrig.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
titleIndex[i] = {
norm, normOrig, words,
title_name: r.title_name,
original_title: r.original_title,
tmdb_id: r.tmdb_id || 0,
category_name: r.category_name,
title_poster: r.title_poster,
created_at: r.created_at,
};
if (norm) push(titleByNorm, norm, i);
if (normOrig && normOrig !== norm) push(titleByNorm, normOrig, i);
for (const w of words) {
push(tokenIndex, w, i);
if (w.length >= 2) push(prefixIndex, w.slice(0, 2), i);
}
}
this.titleIndex = titleIndex;
this.tokenIndex = tokenIndex;
this.titleByNorm = titleByNorm;
this.prefixIndex = prefixIndex;
console.log(`[LocalDB] Index construit: ${titleIndex.length} titres en ${Date.now() - t0}ms ` +
`(tokens=${tokenIndex.size}, prefixes=${prefixIndex.size})`);
}
private mapCategoryToType(category: string): MediaType {
const cat = (category || '').toLowerCase().trim();
// Livres & BD
if (cat.match(/\b(bd|livres?|ebooks?|magazines?|journaux)\b/)) return 'book';
// Jeux
if (cat.match(/\b(jeux?|consoles?)\b/)) return 'game';
// Logiciels & Formations
if (cat.match(/\b(logiciels?|formations?)\b/)) return 'software';
// Musique
if (cat.match(/\b(musiques?|audio)\b/)) return 'music';
// Séries
if (cat.includes('série') || cat.includes('serie') || cat.includes('tv') || cat.includes('emission')) return 'series';
// Animes / Dessins animés
if (cat.includes('anime') || cat.includes('manga') || cat.includes('dessin')) return 'anime';
// Films (Films HD, Documentaires, Spectacles...)
if (cat.includes('film') || cat.includes('spectacle') || cat.includes('documentaire') || cat === '') return 'movie';
// Tout le reste
return 'other';
}
async search(query: string, mediaType: any = 'movie'): Promise<SearchResult[]> {
if (!this.initDb()) {
console.warn('[LocalDB] ⚠️ Base de données non initialisée ou introuvable.');
return [];
}
this.buildTitleIndex();
if (!this.titleIndex || this.titleIndex.length === 0) return [];
const t0 = Date.now();
const q = LocalDatabaseAPI.normalize(query);
if (!q) return [];
const tokens = q.split(' ').filter(Boolean);
if (tokens.length === 0) return [];
// Candidate row indices, gathered from the inverted indexes. For
// a typical query this drops the working set from ~104K rows to
// a few hundred. Rows that don't show up here cannot match Tier
// 1, 2, 3 or 5 — the only thing they could theoretically hit is
// Tier 4 substring-inside-a-word, which is rare enough not to
// justify a trigram index.
const candidates = new Set<number>();
const exactHits = this.titleByNorm!.get(q);
if (exactHits) for (const i of exactHits) candidates.add(i);
for (const tok of tokens) {
const rows = this.tokenIndex!.get(tok);
if (rows) for (const i of rows) candidates.add(i);
if (tok.length >= 2) {
const pRows = this.prefixIndex!.get(tok.slice(0, 2));
if (pRows) for (const i of pRows) candidates.add(i);
}
}
const scored: Array<{ idx: number; score: number }> = [];
for (const i of candidates) {
const entry = this.titleIndex[i];
const t = entry.norm;
const o = entry.normOrig;
let score = 0;
// Tier 1: exact normalized match on either title field
if (t === q || (o && o === q)) {
score = 1000;
}
// Tier 2: title starts with the full query
else if (t.startsWith(q) || (o && o.startsWith(q))) {
score = 800;
}
// Tier 3: query appears as a whole-word substring
else if ((' ' + t + ' ').includes(' ' + q + ' ') ||
(o && (' ' + o + ' ').includes(' ' + q + ' '))) {
score = 700;
}
// Tier 4: raw substring (partial word)
else if (t.includes(q) || (o && o.includes(q))) {
score = 600;
}
// Tier 5: per-token matching, exact-then-fuzzy, any word order.
// Uses the precomputed entry.words instead of re-splitting on
// every row.
else {
const words = entry.words;
let exactMatched = 0;
let fuzzyMatched = 0;
let fuzzyPenalty = 0;
let anyMatched = false;
for (const tok of tokens) {
let exact = false;
for (const w of words) {
if (w === tok || w.startsWith(tok)) { exact = true; break; }
}
if (exact) {
exactMatched++;
anyMatched = true;
continue;
}
const budget = LocalDatabaseAPI.fuzzyBudget(tok);
if (budget === 0) continue;
let best = budget + 1;
for (const w of words) {
if (Math.abs(w.length - tok.length) > budget) continue;
const d = LocalDatabaseAPI.editDistance(tok, w, budget);
if (d < best) { best = d; if (best <= 1) break; }
}
if (best <= budget) {
fuzzyMatched++;
fuzzyPenalty += best;
anyMatched = true;
}
}
const totalMatched = exactMatched + fuzzyMatched;
if (totalMatched === tokens.length) {
// All tokens covered — strong signal even when some were fuzzy
score = 400 - fuzzyPenalty * 30 + exactMatched * 5;
} else if (anyMatched) {
// Partial coverage — only meaningful for multi-word queries
score = Math.round(120 * (totalMatched / tokens.length)) - fuzzyPenalty * 10;
}
}
if (score > 0) {
// Tiebreakers: shorter titles win; original_title field is a small bonus when it helped
score += Math.max(0, 30 - t.length);
scored.push({ idx: i, score });
}
}
scored.sort((a, b) => b.score - a.score);
const results: SearchResult[] = scored.slice(0, 150).map(({ idx }) => {
const r = this.titleIndex![idx];
const type = this.mapCategoryToType(r.category_name);
return {
title: r.title_name,
year: r.created_at ? r.created_at.substring(0, 4) : null,
image: r.title_poster || null,
hrefPath: `localdb:${r.tmdb_id}:${r.title_name}`,
type,
source: this.name
};
});
const filtered = (mediaType === 'movie')
? results.filter(r => r.type === 'movie' || r.type === 'anime')
: (mediaType === 'series')
? results.filter(r => r.type === 'series' || r.type === 'anime')
: (mediaType === 'movie_series')
? results.filter(r => r.type === 'movie' || r.type === 'series' || r.type === 'anime')
: results.filter(r => r.type === mediaType);
console.log(`[LocalDB] search "${query}" → ${candidates.size} candidats, ${filtered.length} résultats en ${Date.now() - t0}ms`);
return filtered;
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
// Pas de tendances en base de données locale
return [];
}
// Distinct quality/host values from the DB, grouped by media bucket.
// Cached after the first call — the underlying data is static.
private optionsCache: { qualities: { movies: string[]; series: string[] }; hosts: string[] } | null = null;
listConfigOptions(): { qualities: { movies: string[]; series: string[] }; hosts: string[] } {
if (this.optionsCache) return this.optionsCache;
const empty = { qualities: { movies: [] as string[], series: [] as string[] }, hosts: [] as string[] };
if (!this.initDb()) return empty;
try {
const movieCats = ['Films', 'Animes', 'Films et series', 'Documentaire', 'Spectacle'];
const seriesCats = ['Séries', 'Animes', 'Téléréalité', 'Émissions TV', 'Mangas'];
const sql = (cats: string[]) => `
SELECT DISTINCT quality_name FROM links_small
WHERE category_name IN (${cats.map(() => '?').join(',')})
AND quality_name IS NOT NULL AND quality_name != ''
ORDER BY quality_name`;
const pick = (cats: string[]): string[] =>
this.db.prepare(sql(cats)).all(...cats).map((r: any) => r.quality_name);
const hosts = this.db.prepare(
`SELECT DISTINCT host_name FROM links_small
WHERE host_name IS NOT NULL AND host_name != ''
ORDER BY host_name`
).all().map((r: any) => r.host_name);
this.optionsCache = {
qualities: { movies: pick(movieCats), series: pick(seriesCats) },
hosts,
};
return this.optionsCache;
} catch (e: any) {
console.error('[LocalDB] listConfigOptions error:', e.message);
return empty;
}
}
private parseIdentifier(identifier: string): { tmdbId: number; titleName: string } {
const parts = identifier.split(':');
if (parts[0] === 'localdb') {
return {
tmdbId: parseInt(parts[1], 10) || 0,
titleName: parts.slice(2).join(':')
};
}
return { tmdbId: 0, titleName: identifier };
}
async getContentLinks(identifier: string, season: number = 1): Promise<ContentLinks> {
if (!this.initDb()) return { links: [] };
const { tmdbId, titleName } = this.parseIdentifier(identifier);
try {
let categoryStmt = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1');
let sample = categoryStmt.get(tmdbId, titleName) as any;
if (!sample && tmdbId > 0) {
sample = categoryStmt.get(0, titleName) as any;
}
if (!sample) return { links: [] };
const mediaType = this.mapCategoryToType(sample.category_name);
if (!mediaType) return { links: [] };
const isSeries = mediaType === 'series';
let rows: any[] = [];
if (isSeries) {
const sql = `
SELECT * FROM links_small
WHERE (tmdb_id = ? OR title_name = ?) AND season_number = ?
ORDER BY episode_number ASC, quality_name DESC
`;
rows = this.db.prepare(sql).all(tmdbId, titleName, season) as any[];
} else {
const sql = `
SELECT * FROM links_small
WHERE tmdb_id = ? OR title_name = ?
ORDER BY quality_name DESC
`;
rows = this.db.prepare(sql).all(tmdbId, titleName) as any[];
}
const splitLangs = (s: string | null | undefined): string[] => {
if (!s) return [];
return s.split(/[,;/]+/).map(p => p.trim()).filter(Boolean);
};
const links: VideoLink[] = rows.map((row: any, i: number) => {
const idKey = row.link_id != null ? String(row.link_id) : `local_${i}`;
const audioLangs = splitLangs(row.audio_langs);
const subLangs = splitLangs(row.sub_langs);
// Legacy `langs` field — kept for plugins/clients that don't
// know about audioLangs/subLangs yet.
const langsList = [...audioLangs];
if (subLangs.length) langsList.push(`Subs: ${subLangs.join(', ')}`);
return {
id: idKey,
host: row.host_name || 'Inconnu',
url: row.link_url || null,
size: row.size_human || '0 Bytes',
sizeBytes: row.size_bytes || 0,
quality: row.quality_name || 'BDRip',
langs: langsList,
episode: row.is_full_season
? 'Saison complète'
: (row.episode_number ? `Épisode ${row.episode_number}` : null),
episodeNumber: row.episode_number || null,
episodeName: row.episode_name || null,
isFullSeason: !!row.is_full_season,
audioLangs,
subLangs,
};
});
return { links };
} catch (e: any) {
console.error('[LocalDB] Erreur getContentLinks:', e.message);
return { links: [] };
}
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.initDb()) return { links: [], seasons: [], isSeries: false };
const { tmdbId, titleName } = this.parseIdentifier(identifier);
try {
const sample = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1').get(tmdbId, titleName) as any;
if (!sample) return { links: [], seasons: [], isSeries: false };
const mediaType = this.mapCategoryToType(sample.category_name);
if (!mediaType) return { links: [], seasons: [], isSeries: false };
const isSeries = mediaType === 'series';
let seasonsList: any[] = [];
let currentSeason = 1;
if (isSeries) {
const seasonsRows = this.db.prepare(`
SELECT DISTINCT season_number
FROM links_small
WHERE tmdb_id = ? OR title_name = ?
ORDER BY season_number ASC
`).all(tmdbId, titleName) as any[];
seasonsList = seasonsRows.map((r: any) => ({
label: `Saison ${r.season_number}`,
value: r.season_number
}));
if (seasonValue) {
currentSeason = parseInt(String(seasonValue), 10) || 1;
} else if (seasonsRows.length > 0) {
// Prefer season 1 if it exists (matches the UI's auto-selected
// dropdown option); otherwise fall back to the lowest season
// number — usually "Saison 0" specials.
const hasSeason1 = seasonsRows.some((r: any) => r.season_number === 1);
currentSeason = hasSeason1 ? 1 : seasonsRows[0].season_number;
}
}
const content = await this.getContentLinks(identifier, currentSeason);
return {
links: content.links,
seasons: seasonsList,
isSeries
};
} catch (e: any) {
console.error('[LocalDB] Erreur getSelection:', e.message);
return { links: [], seasons: [], isSeries: false };
}
}
resolveLocalLink(linkId: string | number): string | null {
if (!this.initDb()) return null;
try {
const row = this.db.prepare('SELECT link_url FROM links_small WHERE link_id = ? LIMIT 1').get(linkId) as any;
if (row && row.link_url) {
return row.link_url;
}
} catch (e: any) {
console.error('[LocalDB] Erreur resolveLocalLink:', e.message);
}
return null;
}
}
// Enregistrement automatique du plugin
sourceRegistry.register(new LocalDatabaseAPI());
+37
View File
@@ -0,0 +1,37 @@
/**
* Appels réseau pour zt.news.
* Pas de challenge CF actif, fetch direct simple.
*/
const TIMEOUT = 20_000;
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0';
async function ztnGet(url: string): Promise<string> {
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
},
redirect: 'follow',
signal: AbortSignal.timeout(TIMEOUT),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
export async function fetchSearch(baseUrl: string, query: string): Promise<string> {
return ztnGet(`${baseUrl}/?p=films&search=${encodeURIComponent(query)}`);
}
/**
* News n'a pas vraiment de page "nouveautés" séparée — la home expose déjà
* une grille de blocs cover_global avec les derniers films/séries.
*/
export async function fetchTrending(baseUrl: string, type: 'films' | 'series'): Promise<string> {
return ztnGet(`${baseUrl}/?p=${type}`);
}
export async function fetchPage(pageUrl: string): Promise<string> {
return ztnGet(pageUrl);
}
+135
View File
@@ -0,0 +1,135 @@
import { ISource, SearchResult, MediaType, SelectionData, ContentLinks } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { fetchSearch, fetchTrending, fetchPage } from './api.js';
import { parseListingHTML, parseContentHTML, parseOtherVersions } from './parser.js';
function isSeriesIdentifier(identifier: string): boolean {
return /[?&]p=serie\b|telecharger-serie/i.test(identifier);
}
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
const seen = new Set<string>();
return results.filter(r => {
const key = normalizeTitle(r.title);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export class ZtTeamAPI implements ISource {
name = 'ztteam';
displayName = 'ZT (Team)';
private baseUrl: string | undefined;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl?.replace(/\/$/, '');
}
async healthCheck(): Promise<boolean> {
if (!this.baseUrl) {
console.warn('[ztnews] ⚠️ ZTTEAM_URL non définie.');
return false;
}
return true;
}
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
if (!query || query.length < 3) throw new Error('La recherche nécessite au moins 3 caractères.');
const html = await fetchSearch(this.baseUrl, query);
let results = parseListingHTML(html, this.baseUrl);
if (mediaType === 'movie') {
results = results.filter(r => r.type === 'movie' || r.type === 'anime');
} else {
results = results.filter(r => r.type === 'series' || r.type === 'anime');
}
return deduplicateByTitle(results);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchTrending(this.baseUrl, mediaType === 'series' ? 'series' : 'films');
const results = parseListingHTML(html, this.baseUrl);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ztnews] Erreur trending ${mediaType}:`, e.message);
return [];
}
}
async getRecent(): Promise<SearchResult[]> {
if (!this.baseUrl) return [];
try {
const html = await fetchPage(this.baseUrl);
const results = parseListingHTML(html, this.baseUrl);
return deduplicateByTitle(results).slice(0, 20);
} catch (e: any) {
console.error(`[ztnews] Erreur getRecent:`, e.message);
return [];
}
}
async getContentLinks(identifier: string): Promise<ContentLinks> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
const url = identifier.startsWith('http') ? identifier : `${this.baseUrl}/${identifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
return parseContentHTML(html, isSeriesIdentifier(identifier));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
if (!this.baseUrl) throw new Error('ZTTEAM_URL non configurée.');
const targetIdentifier = seasonValue ? String(seasonValue) : identifier;
const isSeries = isSeriesIdentifier(targetIdentifier);
const url = targetIdentifier.startsWith('http') ? targetIdentifier : `${this.baseUrl}/${targetIdentifier.replace(/^\//, '')}`;
const html = await fetchPage(url);
const content = parseContentHTML(html, isSeries);
const allLinks = [...content.links];
let seasons: { label: string; value: string }[] = [];
if (!isSeries) {
const otherVersions = parseOtherVersions(html, this.baseUrl);
if (otherVersions.length > 0) {
console.log(`[ztnews] Fetching ${otherVersions.length} other qualities concurrently...`);
const qualityPromises = otherVersions.map(async (q) => {
try {
const qHtml = await fetchPage(q.value);
const qContent = parseContentHTML(qHtml, isSeries);
return qContent.links;
} catch (e) {
console.error(`[ztnews] Error fetching quality page ${q.value}:`, e);
return [];
}
});
const otherQualitiesLinks = await Promise.all(qualityPromises);
otherQualitiesLinks.forEach(links => allLinks.push(...links));
}
}
return {
links: allLinks,
seasons,
isSeries,
};
}
async resolveLink(linkId: string): Promise<string | null> {
console.log(`[ztTeam] 🔗 Renvoi du lien dl-protect brut (résolution via navigateur ou JDownloader requise) : ${linkId}`);
return linkId || null;
}
}
sourceRegistry.register(new ZtTeamAPI(CONFIG.ZTTEAM_URL));
+169
View File
@@ -0,0 +1,169 @@
import { SearchResult, ContentLinks, VideoLink } from '../../src/types/source.js';
function decodeFnMeta(url: string): { quality?: string; langs?: string[] } {
try {
const m = url.match(/[?&]fn=([^&]+)/);
if (!m) return {};
const decoded = Buffer.from(decodeURIComponent(m[1]!), 'base64').toString('utf-8');
const qm = decoded.match(/\[([^\]]+)\]/);
const quality = qm ? qm[1]!.trim() : undefined;
// Tout après " - " jusqu'à la fin (typiquement la langue : FRENCH, MULTI, VOSTFR…)
const lm = decoded.match(/-\s+([A-Za-z]+(?:\s+[A-Za-z]+)?)$/);
const langs = lm ? [lm[1]!.trim()] : undefined;
return { quality, langs };
} catch {
return {};
}
}
function detectType(href: string): 'movie' | 'series' | 'anime' {
if (/[?&]p=serie\b|telecharger-serie|serie-/i.test(href)) return 'series';
if (/animes?/i.test(href)) return 'anime';
return 'movie';
}
function absUrl(url: string, baseUrl: string): string {
if (url.startsWith('http')) return url;
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + (url.startsWith('/') ? url : '/' + url);
}
/**
* Strip suffixes de qualité/langue pour dedup par titre normalisé.
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/\b(web-?dl|web-?rip|blu-?ray|hdtv|hdrip|dvdrip|hdlight|truefrench|french|multi(?:langues?)?|vff|vf|vostfr|x264|x265|hevc)\b/g, '')
.replace(/\b(720p|1080p|2160p|4k|uhd|3d|sd|hd)\b/g, '')
.replace(/\(\s*\d{4}\s*\)/g, '')
.replace(/-\s*saison\s*\d+/gi, '')
.replace(/[^a-z0-9]/g, '');
}
function deduplicateByTitle<T extends { title: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter(it => {
const k = normalizeTitle(it.title);
if (!k || seen.has(k)) return false;
seen.add(k);
return true;
});
}
/**
* News utilise la structure DLE classique avec cover_global / cover_infos_title / mainimg
* sur la home/listing/recherche. On peut donc partager le parser de listing.
*/
export function parseListingHTML(html: string, baseUrl: string): SearchResult[] {
const results: SearchResult[] = [];
const coverRegex = /<div class="cover_global"[^>]*>([\s\S]*?)(?=<div class="cover_global"|$)/g;
let m: RegExpExecArray | null;
while ((m = coverRegex.exec(html)) !== null) {
const block = m[1]!;
const titleMatch = block.match(/<div class="cover_infos_title"[^>]*>\s*<a href="([^"]+)"[^>]*>\s*([^<]+)/);
if (!titleMatch) continue;
const href = absUrl(titleMatch[1]!.trim(), baseUrl);
const title = titleMatch[2]!.trim();
const imgMatch = block.match(/<img class="mainimg"[^>]*src="([^"]+)"/);
const image = imgMatch ? absUrl(imgMatch[1]!, baseUrl) : null;
results.push({
title,
year: null,
image,
hrefPath: href,
type: detectType(titleMatch[1]!),
source: 'ztnews',
});
}
return deduplicateByTitle(results);
}
/**
* Parse la fiche film/série de news.
* Structure dans <div class="postinfo">:
* <div style="color:#XXX">HOST_NAME</div>
* <a href="dl-protect.link/SLUG?fn=...&rl=a2">Télécharger</a> (film)
* <a href="dl-protect.link/SLUG?fn=...&rl=b2">Episode N</a> (série, plusieurs liens)
*/
export function parseContentHTML(html: string, isSeries: boolean): ContentLinks {
const links: VideoLink[] = [];
const postMatch = html.match(/<div class="postinfo">([\s\S]*?)<\/div>\s*<\/center>/);
if (!postMatch) return { links };
const post = postMatch[1]!;
// Découper par hôte : chaque hôte est marqué par <div style="font-weight:bold;color:#XXX">HOST</div>
const hostSplit = post.split(/<div\s+style="font-weight:bold;color:#[0-9a-fA-F]+">([^<]+)<\/div>/);
// hostSplit[0] = pre-section, puis alterne (HOST, BLOCK)
for (let i = 1; i < hostSplit.length; i += 2) {
const host = hostSplit[i]!.trim();
const block = hostSplit[i + 1] || '';
// Tous les <a href="dl-protect.link..."> dans cette section
const linkRegex = /<a[^>]+href="(https?:\/\/dl-protect\.link\/[0-9a-fA-F]+\?[^"]*?rl=[ab]2[^"]*)"[^>]*>([^<]+)<\/a>/g;
let lm: RegExpExecArray | null;
while ((lm = linkRegex.exec(block)) !== null) {
const url = lm[1]!;
const label = lm[2]!.trim();
const epMatch = label.match(/Episode\s*(\d+|FiNAL|Final|final)/i);
const meta = decodeFnMeta(url);
let quality = meta.quality || 'Inconnu';
let langs: string[] = [];
let subs: string[] = [];
const textToScan = `${quality} ${label}`;
const langMatch = textToScan.match(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi);
if (langMatch) {
const seenLangs = new Set<string>();
const seenSubs = new Set<string>();
langMatch.forEach(l => {
const up = l.toUpperCase();
if (up.includes('VOSTFR')) { seenLangs.add('VOSTFR'); seenSubs.add('French'); }
else if (up.includes('TRUEFRENCH')) seenLangs.add('TrueFrench');
else if (up.includes('FRENCH') || up === 'VF' || up === 'VFF') seenLangs.add('French');
else if (up.includes('MULTI')) { seenLangs.add('MULTI'); seenSubs.add('Multi'); }
});
langs = Array.from(seenLangs);
subs = Array.from(seenSubs);
quality = quality.replace(/\b(MULTI(?:LANGUES?)?|TRUEFRENCH|FRENCH|VOSTFR|VFF|VF)\b/gi, '').trim();
}
quality = quality.replace(/[\(\)\[\]\-]+$/g, '').replace(/[\(\)\[\]]/g, '').replace(/\s+/g, ' ').trim();
if (!quality || quality.toLowerCase() === 'inconnu') quality = 'WEB';
links.push({
id: url,
host: host.toLowerCase(),
label: isSeries ? `${label}${host}` : host,
episode: epMatch ? epMatch[1] : undefined,
quality: quality,
langs: langs,
subs: subs,
url: null,
});
}
}
return { links };
}
/**
* Extrait les autres versions/qualités du film depuis la section "Qualités également disponibles".
*/
export function parseOtherVersions(html: string, baseUrl: string): { label: string; value: string }[] {
const out: { label: string; value: string }[] = [];
const sectionMatch = html.match(/<div class="otherversions"[\s\S]*?<\/div>/);
if (!sectionMatch) return out;
const linkRegex = /<a\s+href="([^"]+)"[^>]*>\s*<span class="otherquality">([\s\S]*?)<\/span>\s*<\/a>/g;
let m: RegExpExecArray | null;
while ((m = linkRegex.exec(sectionMatch[0])) !== null) {
const href = absUrl(m[1]!, baseUrl);
const label = m[2]!.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
if (label && !out.find(o => o.value === href)) out.push({ label, value: href });
}
return out;
}