ci: fix pipeline, update readme and anonymize ZT references
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user