Initial commit (v1.5.9)

This commit is contained in:
2026-09-15 21:45:47 +02:00
commit b8d3dd52ec
89 changed files with 13429 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();
}
+162
View File
@@ -0,0 +1,162 @@
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 ZoneTelechargementAPI implements ISource {
name = 'zt';
displayName = 'Zone-Téléchargement';
get baseUrl() {
return CONFIG.ZT_URL?.replace(/\/$/, '');
}
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 ZoneTelechargementAPI());
+191
View File
@@ -0,0 +1,191 @@
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';
}
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || href.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({ title, image, hrefPath: href, year, 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|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;
}
+112
View File
@@ -0,0 +1,112 @@
import { SearchResult, VideoLink, SeasonOption, SelectionData, ContentLinks } from '../../src/types/source.js';
import { FlixArtAuth } from './auth.js';
import { FlixArtParser } from './parser.js';
import { CONFIG } from '../../src/utils/config.js';
export class FlixArtAPI {
private static get baseUrl() { return CONFIG.FLIXART_URL || ''; }
private static get ajaxUrl() { return `${this.baseUrl}/wp-admin/admin-ajax.php`; }
private static userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
// Cache the film page data temporarily for resolveLink
private static contextCache: { [url: string]: { postId: string, nonce: string, type: string } } = {};
private static async fetchWithAuth(url: string, options: RequestInit = {}, retries = 1): Promise<Response> {
try {
const cookie = await FlixArtAuth.getCookie();
const headers = new Headers(options.headers || {});
headers.set('User-Agent', this.userAgent);
headers.set('Cookie', cookie);
headers.set('Origin', this.baseUrl);
headers.set('Referer', this.baseUrl);
const response = await fetch(url, { ...options, headers });
// If FlixArt returns 403 or redirects to login, refresh cookie and retry
if (response.status === 403 && retries > 0) {
console.log('[FlixArt] Session expirée, renouvellement du cookie...');
await FlixArtAuth.getCookie(true);
return this.fetchWithAuth(url, options, retries - 1);
}
return response;
} catch (error) {
if (retries > 0) {
await FlixArtAuth.getCookie(true);
return this.fetchWithAuth(url, options, retries - 1);
}
throw error;
}
}
public static async search(query: string, mediaType?: string): Promise<SearchResult[]> {
const body = new URLSearchParams({
action: 'flixart_header_search',
s: query,
search: query,
type_query: 'all',
post_type: mediaType === 'series' ? 'tv_shows' : 'movies'
});
// Search works without auth, but we use fetchWithAuth just in case
const res = await this.fetchWithAuth(this.ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: body.toString()
});
const data = await res.json();
if (data.success && data.data && data.data.results) {
return FlixArtParser.parseSearchAjax(data.data.results);
}
return [];
}
public static async getTrending(mediaType: 'movie' | 'series'): Promise<SearchResult[]> {
const res = await this.fetchWithAuth(this.baseUrl, { method: 'GET' });
const html = await res.text();
return FlixArtParser.parseTrending(html, mediaType === 'series');
}
public static async getSelection(url: string): Promise<SelectionData> {
const res = await this.fetchWithAuth(url, { method: 'GET' });
const html = await res.text();
const parsed = FlixArtParser.parseSelection(html);
// Cache post data for resolveLink
if (parsed.postId && parsed.nonce) {
this.contextCache[url] = {
postId: parsed.postId,
nonce: parsed.nonce,
type: parsed.isSeries ? 'tv_shows' : 'movies' // Note: actually parser returns isSeries. Captcha needs 'movies' or 'tv_shows'
};
}
// Prefix ID with url to pass state to resolveLink
parsed.links.forEach((link: any) => {
link.id = `${url}|${link.id}`;
});
return {
links: parsed.links,
seasons: parsed.seasons,
isSeries: parsed.isSeries
};
}
public static async getContentLinks(url: string, season?: number): Promise<ContentLinks> {
// Not used heavily if getSelection is prioritized, but we need to fetch the season HTML via ajax
// For simplicity, if season is passed, we fetch season content via AJAX.
// Actually, FlixArt loads all episodes HTML when you click a season tab.
// For now, getSelection is sufficient.
const selection = await this.getSelection(url);
return { links: selection.links };
}
}
+148
View File
@@ -0,0 +1,148 @@
import { CONFIG } from '../../src/utils/config.js';
import fs from 'fs';
import path from 'path';
export class FlixArtAuth {
private static sessionCookie: string | null = null;
private static lastLoginTime: number = 0;
private static readonly COOKIE_FILE = path.resolve(process.cwd(), 'database', 'flixart_cookie.txt');
public static async getCookie(forceRefresh = false): Promise<string> {
if (!this.sessionCookie && fs.existsSync(this.COOKIE_FILE)) {
try {
const stats = fs.statSync(this.COOKIE_FILE);
// Si le cookie a moins de 7 jours, on le réutilise (le renouvellement se fera si on obtient une 403)
if (Date.now() - stats.mtimeMs < 7 * 24 * 60 * 60 * 1000) {
this.sessionCookie = fs.readFileSync(this.COOKIE_FILE, 'utf-8');
this.lastLoginTime = stats.mtimeMs;
}
} catch (e) {
console.warn('[FlixArt Auth] Impossible de lire le cookie sauvegardé:', e);
}
}
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
return this.sessionCookie;
}
const username = CONFIG.FLIXART_USERNAME;
const password = CONFIG.FLIXART_PASSWORD;
const baseUrl = CONFIG.FLIXART_URL || '';
const ajaxUrl = `${baseUrl}/wp-admin/admin-ajax.php`;
if (!username || !password) {
throw new Error('[FlixArt Auth] Identifiants manquants.');
}
console.log(`[FlixArt] Tentative de connexion avec l'utilisateur: ${username}...`);
try {
// 1. Obtenir un nouveau nonce de login
const refreshParams = new URLSearchParams({
action: 'flixart_auth_refresh_nonces'
});
const refreshRes = await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl
},
body: refreshParams.toString()
});
const refreshData = await refreshRes.json();
if (!refreshData.success || !refreshData.data || !refreshData.data.loginNonce) {
throw new Error("Impossible d'obtenir le nonce de connexion.");
}
const loginNonce = refreshData.data.loginNonce;
const refreshCookies = (refreshRes.headers.getSetCookie ? refreshRes.headers.getSetCookie() : [refreshRes.headers.get('set-cookie') || '']).map(c => c.split(';')[0]).filter(Boolean);
const refreshCookieStr = refreshCookies.join('; ');
// 2. Se connecter
const dataParams = new URLSearchParams();
dataParams.append('log', username);
dataParams.append('pwd', password);
dataParams.append('redirect', baseUrl + '/membership-account/');
const loginParams = new URLSearchParams();
loginParams.append('action', 'flixart_auth_login');
loginParams.append('nonce', loginNonce);
loginParams.append('data', dataParams.toString());
const loginRes = await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
'Origin': baseUrl,
'Referer': baseUrl + '/',
'Cookie': refreshCookieStr
},
body: loginParams.toString(),
redirect: 'manual'
});
const loginBody = await loginRes.clone().json().catch(() => ({}));
if (loginBody.success === false) {
const code = loginBody.data?.code || loginBody.data?.[0]?.code;
if (code === 'too_many_devices') {
console.log(`[FlixArt] ⚠️ Limite d'appareils atteinte. Tentative de libération...`);
const recoveryParams = new URLSearchParams({
action: 'flixart_device_recovery',
nonce: refreshData.data.deviceRecoveryNonce,
username: username,
password: password
});
await fetch(ajaxUrl, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Referer': baseUrl,
'Cookie': refreshCookieStr
},
body: recoveryParams.toString()
});
console.log(`[FlixArt] ✅ Appareils libérés, nouvelle tentative de connexion...`);
return this.getCookie(true);
}
throw new Error(loginBody.data?.[0]?.message || loginBody.data?.message || 'Échec de la connexion.');
}
// FlixArt returns 200 OK with success: true and sets cookies
const setCookieHeader = loginRes.headers.get('set-cookie') || loginRes.headers.get('Set-Cookie');
let cookies: string[] = [];
if (setCookieHeader) {
const setCookieHeaders = loginRes.headers.getSetCookie ? loginRes.headers.getSetCookie() : [setCookieHeader];
cookies = setCookieHeaders.map(c => c.split(';')[0]);
}
if (!cookies.some(c => c.includes('wordpress_logged_in_'))) {
console.warn(`[FlixArt] ⚠️ Pas de cookie wordpress_logged_in trouvé.`);
throw new Error('Échec de la connexion (Pas de cookie de session complet).');
}
this.sessionCookie = cookies.join('; ');
this.lastLoginTime = Date.now();
try {
fs.writeFileSync(this.COOKIE_FILE, this.sessionCookie, 'utf-8');
} catch (e) {
console.warn('[FlixArt Auth] Impossible de sauvegarder le cookie:', e);
}
console.log(`[FlixArt] ✅ Connexion réussie ! (Cookie généré)`);
return this.sessionCookie;
} catch (error: any) {
console.error('[FlixArt] ❌ Erreur lors de la connexion:', error.message);
throw error;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { FlixArtAuth } from './auth.js';
import fs from 'fs';
import { CONFIG } from '../../src/utils/config.js';
async function dump() {
const cookie = await FlixArtAuth.getCookie();
const baseUrl = CONFIG.FLIXART_URL || '';
const res = await fetch(`${baseUrl}/film/avatar/`, {
headers: { 'Cookie': cookie, 'User-Agent': 'Mozilla/5.0' }
});
const html = await res.text();
fs.writeFileSync('scratch/avatar.html', html);
console.log('Saved to scratch/avatar.html, length:', html.length);
}
dump();
+56
View File
@@ -0,0 +1,56 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
import { FlixArtAPI } from './api.js';
export class FlixartSource implements ISource {
name = 'flixart';
displayName = 'FlixArt';
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
return FlixArtAPI.search(query, mediaType);
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const type = mediaType === 'series' ? 'series' : 'movie';
return FlixArtAPI.getTrending(type);
}
async getRecent(): Promise<SearchResult[]> {
return this.getTrending('movie');
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
return FlixArtAPI.getSelection(identifier);
}
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
return FlixArtAPI.getContentLinks(identifier, season);
}
async healthCheck(): Promise<boolean> {
try {
const results = await this.getTrending('movie');
return results.length > 0;
} catch (e: any) {
console.error(`[FlixArt] Healthcheck failed: ${e.message}`);
return false;
}
}
// Custom resolveLink that returns a Turnstile challenge instead of just the URL
// Actually, Agora's activeSource.resolveLink only accepts string.
// We will change ISource resolveLink to allow returning an object.
async resolveLink(linkId: string, extraData?: any): Promise<any> {
const [url] = linkId.split('|');
// FlixArt requires a Cloudflare Turnstile challenge which cannot be resolved on localhost.
// We directly return the manual redirection challenge.
return {
captcha: 'turnstile',
url: url,
sourceName: 'FlixArt'
};
}
}
// Auto-registration
import { sourceRegistry } from '../../src/core/registry.js';
sourceRegistry.register(new FlixartSource());
+167
View File
@@ -0,0 +1,167 @@
import { SearchResult, VideoLink, SeasonOption } from '../../src/types/source.js';
function getMediaTypeFromUrl(url: string): 'movie' | 'series' {
return url.includes('/serie/') ? 'series' : 'movie';
}
function unescapeHtml(html: string): string {
return html
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#038;/g, '&');
}
export class FlixArtParser {
static parseSearchAjax(htmlStr: string): SearchResult[] {
const results: SearchResult[] = [];
const cardRegex = /<a class="[^"]*flixart-search-card[^"]*" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
let match;
while ((match = cardRegex.exec(htmlStr)) !== null) {
const href = unescapeHtml(match[1]);
const inner = match[2];
let title = '';
const titleMatch = inner.match(/<span class="flixart-search-result-title">([^<]+)<\/span>/);
if (titleMatch) title = unescapeHtml(titleMatch[1].trim());
let year = null;
const yearMatch = inner.match(/<span class="video-years">([^<]+)<\/span>/);
if (yearMatch) year = yearMatch[1].trim();
let image = null;
const imgMatch = inner.match(/<img[^>]+src="([^"]+)"/);
if (imgMatch) {
image = unescapeHtml(imgMatch[1]);
if (image.includes('&quality=')) image = image.split('&quality=')[0];
}
if (title && href) {
results.push({
title,
year,
image,
hrefPath: href,
type: getMediaTypeFromUrl(href),
source: 'flixart'
});
}
}
return results;
}
static parseTrending(htmlStr: string, isSeries: boolean): SearchResult[] {
const results: SearchResult[] = [];
const sectionTitle = isSeries ? 'Top 10 séries du jour' : 'Top 10 films du jour';
const fallbackTitle = isSeries ? 'Nouveautés séries' : 'Nouveautés films';
// Find section containing the title
let sectionRegexStr = `<section class="fx-section">\\s*<div class="fx-section-head">\\s*<h2>(${sectionTitle}|${fallbackTitle})<\\/h2>[\\s\\S]*?<\\/section>`;
let sectionMatch = htmlStr.match(new RegExp(sectionRegexStr, 'i'));
if (!sectionMatch) return results;
const sectionHtml = sectionMatch[0];
const cardRegex = /<article class="fx-card[^"]*">([\s\S]*?)<\/article>/g;
let match;
while ((match = cardRegex.exec(sectionHtml)) !== null) {
const inner = match[1];
let href = null;
let title = '';
const titleMatch = inner.match(/<h3 class="fx-card-title"><a href="([^"]+)"[^>]*>([^<]+)<\/a><\/h3>/);
if (titleMatch) {
href = unescapeHtml(titleMatch[1]);
title = unescapeHtml(titleMatch[2].trim());
}
let image = null;
const imgMatch = inner.match(/<img src="([^"]+)"/);
if (imgMatch) {
image = unescapeHtml(imgMatch[1]);
if (image.includes('&quality=')) image = image.split('&quality=')[0];
}
let year = null;
const yearMatch = inner.match(/<span>(\d{4})<\/span>\s*<\/span>/);
if (yearMatch) {
year = yearMatch[1];
}
if (title && href) {
results.push({
title,
year,
image,
hrefPath: href,
type: isSeries ? 'series' : 'movie',
source: 'flixart'
});
}
}
return results;
}
static parseSelection(htmlStr: string): { links: VideoLink[], seasons: SeasonOption[], isSeries: boolean, postId: string, nonce: string | null } {
const links: VideoLink[] = [];
const seasons: SeasonOption[] = [];
let isSeries = false;
let postId = '';
const postIdMatch = htmlStr.match(/data-post-id="(\d+)"/);
if (postIdMatch) postId = postIdMatch[1];
let nonce: string | null = null;
const nonceMatch = htmlStr.match(/flixartDownloadCaptcha\s*=\s*\{[^}]*nonce:\s*'([^']+)'/);
if (nonceMatch) nonce = nonceMatch[1];
const seasonTabRegex = /<button[^>]+class="[^"]*flixart-season-tab[^"]*"[^>]+data-season="([^"]+)"[^>]*>([\s\S]*?)<\/button>/g;
let match;
while ((match = seasonTabRegex.exec(htmlStr)) !== null) {
isSeries = true;
const val = match[1];
const inner = match[2];
const numMatch = inner.match(/<span class="flixart-season-tab__number">([^<]+)<\/span>/);
if (numMatch) {
seasons.push({ label: `Saison ${numMatch[1].trim()}`, value: val });
}
}
const rowRegex = /<div role="row" class="jws-lien-row[^"]*"[^>]*data-qualite="([^"]*)"[^>]*data-langue="([^"]*)"[^>]*>([\s\S]*?)<\/div>/g;
while ((match = rowRegex.exec(htmlStr)) !== null) {
const inner = match[3];
let episode = null;
const episodeMatch = htmlStr.substring(match.index - 100, match.index).match(/data-episode="([^"]+)"/);
if (episodeMatch) episode = episodeMatch[1];
const checkboxMatch = inner.match(/<input[^>]+data-flixart-download-select[^>]+data-row-index="(\d+)"[^>]*data-download-title="([^"]*)"[^>]*data-download-meta="([^"]*)"/);
if (checkboxMatch) {
const rowIndex = checkboxMatch[1];
const title = unescapeHtml(checkboxMatch[2]);
const meta = unescapeHtml(checkboxMatch[3]);
let host = 'Inconnu';
const lowerMeta = meta.toLowerCase();
if (lowerMeta.includes('1fichier')) host = '1fichier';
else if (lowerMeta.includes('nitroflare')) host = 'nitroflare';
else if (lowerMeta.includes('ddownload')) host = 'ddownload';
links.push({
id: rowIndex,
host,
label: title,
quality: meta,
url: null,
episode
});
}
}
return { links, seasons, isSeries, postId, nonce };
}
}
+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);
}
+140
View File
@@ -0,0 +1,140 @@
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';
get baseUrl() {
return CONFIG.FT_URL?.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.')) {
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());
+213
View File
@@ -0,0 +1,213 @@
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 {
let path = url;
if (url.startsWith('http')) {
try {
const u = new URL(url);
path = u.pathname + u.search + u.hash;
} catch {
return url;
}
}
const cleanedBase = baseUrl.replace(/\/$/, '');
return cleanedBase + '/' + path.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;
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
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);
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || hrefRaw.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
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\.[a-z]+\/[^"]+)"/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;
}
+129
View File
@@ -0,0 +1,129 @@
import { FS24Auth } from './auth.js';
import { CONFIG } from '../../src/utils/config.js';
export class FS24API {
private static get baseUrl(): string {
return CONFIG.FS24_URL;
}
/**
* Recherche AJAX via /engine/ajax/search.php
*/
public static async fetchSearch(query: string, page: number = 1): Promise<string> {
const cookie = await FS24Auth.getCookie();
const searchUrl = `${this.baseUrl}/engine/ajax/search.php`;
const params = new URLSearchParams();
params.append('query', query);
params.append('page', page.toString());
console.log(`[FS24] Recherche: "${query}" (page ${page})`);
const response = await fetch(searchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie,
'X-Requested-With': 'XMLHttpRequest'
},
body: params.toString()
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.text();
}
/**
* Récupère la page HTML d'un contenu pour extraire le news_id
*/
public static async fetchPage(pathOrUrl: string): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${this.baseUrl}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.text();
}
/**
* Récupère la page des tendances (films ou séries)
*/
public static async fetchTrending(mediaType: 'movie' | 'series'): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = mediaType === 'series' ? `${this.baseUrl}/s-tv/` : `${this.baseUrl}/films/`;
console.log(`[FS24] Chargement des tendances ${mediaType}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
return await response.text();
}
/**
* Récupère la page des ajouts récents
*/
public static async fetchRecent(): Promise<string> {
const cookie = await FS24Auth.getCookie();
const url = `${this.baseUrl}/film-commu/`;
console.log(`[FS24] Chargement des ajouts récents`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie
}
});
if (!response.ok) throw new Error(`HTTP Error ${response.status}`);
return await response.text();
}
/**
* Appelle l'API JSON /engine/ajax/release-api.php pour récupérer les releases communautaires.
* C'est ici que se trouvent les vrais liens de téléchargement (fsprotect encodés en base64).
*/
public static async fetchReleases(newsId: string): Promise<any> {
const cookie = await FS24Auth.getCookie();
const url = `${this.baseUrl}/engine/ajax/release-api.php?action=release_list&post_id=${newsId}`;
console.log(`[FS24] Chargement des releases pour post_id=${newsId}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Cookie': cookie,
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}`);
}
return await response.json();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { CONFIG } from '../../src/utils/config.js';
export class FS24Auth {
private static sessionCookie: string | null = null;
private static lastLoginTime: number = 0;
public static async getCookie(forceRefresh = false): Promise<string> {
// If we already have a cookie and it's less than 12 hours old, return it
if (!forceRefresh && this.sessionCookie && Date.now() - this.lastLoginTime < 12 * 60 * 60 * 1000) {
return this.sessionCookie;
}
const username = CONFIG.FS24_USERNAME;
const password = CONFIG.FS24_PASSWORD;
const baseUrl = CONFIG.FS24_URL;
if (!username || !password) {
throw new Error('[FS24 Auth] Identifiants manquants.');
}
console.log(`[FS24] Tentative de connexion avec l'utilisateur: ${username}...`);
try {
const params = new URLSearchParams();
params.append('login_name', username);
params.append('login_password', password);
params.append('login', 'submit');
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': baseUrl
},
body: params.toString(),
redirect: 'manual' // Capture the set-cookie from the redirect
});
// Collect cookies from the response headers
const setCookieHeader = response.headers.get('set-cookie');
if (setCookieHeader) {
// Parse DLE / PHP session cookies
const cookies = setCookieHeader.split(',').map(c => c.split(';')[0].trim());
this.sessionCookie = cookies.join('; ');
this.lastLoginTime = Date.now();
console.log(`[FS24] ✅ Connexion réussie ! (Cookie généré)`);
return this.sessionCookie;
} else {
console.warn(`[FS24] ⚠️ Pas de header set-cookie retourné. Les identifiants sont-ils valides ?`);
throw new Error('Échec de la connexion (Pas de cookie de session).');
}
} catch (error: any) {
console.error('[FS24] ❌ Erreur lors de la connexion:', error.message);
throw error;
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { ISource, SearchResult, ContentLinks, MediaType, SelectionData } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { FS24API } from './api.js';
import { FS24Auth } from './auth.js';
import { parseListingHTML, extractNewsId, parseReleasesJSON } from './parser.js';
export class FS24Source implements ISource {
public readonly name = 'fs24';
public readonly displayName = 'FS24';
public async healthCheck(): Promise<boolean> {
try {
await FS24Auth.getCookie(true);
return true;
} catch (e: any) {
console.error(`[FS24] HealthCheck échoué: ${e.message}`);
return false;
}
}
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
if (!query || query.length < 3) return [];
try {
const html = await FS24API.fetchSearch(query);
const results = parseListingHTML(html, mediaType || 'movie');
return results;
} catch (e: any) {
console.error(`[FS24] Erreur search: ${e.message}`);
return [];
}
}
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
try {
const html = await FS24API.fetchTrending(mediaType === 'series' ? 'series' : 'movie');
const results = parseListingHTML(html, mediaType);
return results.slice(0, 20); // Keep top 20
} catch (e: any) {
console.error(`[FS24] Erreur trending: ${e.message}`);
return [];
}
}
public async getRecent(): Promise<SearchResult[]> {
try {
const html = await FS24API.fetchRecent();
const results = parseListingHTML(html, 'movie'); // Default to movie for recents, TMDB will fix it if needed
return results.slice(0, 20);
} catch (e: any) {
console.error(`[FS24] Erreur recent: ${e.message}`);
return [];
}
}
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
try {
// Step 1: Fetch the page HTML to extract the news_id
const html = await FS24API.fetchPage(identifier);
const newsId = extractNewsId(html);
if (!newsId) {
console.warn(`[FS24] Impossible d'extraire le news_id depuis: ${identifier}`);
return { links: [] };
}
// Step 2: Call the release JSON API to get the actual download links
const releaseData = await FS24API.fetchReleases(newsId);
const links = parseReleasesJSON(releaseData);
console.log(`[FS24] ${links.length} lien(s) trouvé(s) pour post_id=${newsId}`);
return { links };
} catch (e: any) {
console.error(`[FS24] Erreur getContentLinks: ${e.message}`);
return { links: [] };
}
}
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const content = await this.getContentLinks(identifier);
return {
links: content.links,
seasons: [],
isSeries: type === 'series'
};
}
}
// ── Auto-registration ──
sourceRegistry.register(new FS24Source());
+164
View File
@@ -0,0 +1,164 @@
import { SearchResult, VideoLink, MediaType } from '../../src/types/source.js';
/**
* Parse le HTML AJAX de résultats de recherche ou pages catégories FS24.
* Supporte les blocs `.search-item` et `.short`
*/
export function parseListingHTML(html: string, mediaType: MediaType): SearchResult[] {
const results: SearchResult[] = [];
// 1. Matches pour les blocs de recherche AJAX (.search-item)
const searchRegex = /<div class=['"]search-item['"][^>]*onclick="location\.href='([^']+)'"[^>]*>([\s\S]*?)(?=<div class=['"]search-item['"]|$)/g;
let match: RegExpExecArray | null;
while ((match = searchRegex.exec(html)) !== null) {
const hrefPath = match[1]!;
const block = match[2]!;
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
const image = imgMatch ? imgMatch[1]! : null;
const titleMatch = block.match(/<div class=['"]search-title['"]>([^<]+)<\/div>/);
if (!titleMatch) continue;
let titleRaw = titleMatch[1]!.trim();
let year: string | null = null;
const yearMatch = titleRaw.match(/\((\d{4})\)/);
if (yearMatch) {
year = yearMatch[1]!;
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
}
if (titleRaw && hrefPath) {
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
}
}
// 2. Matches pour les pages régulières DLE (.short)
const shortRegex = /<div class=['"]short['"]>([\s\S]*?)<\/div>\s*<!-- \/short -->|<div class=['"]short['"]>([\s\S]*?)(?=<div class=['"]short['"]|$)/g;
while ((match = shortRegex.exec(html)) !== null) {
const block = match[1] || match[2];
if (!block) continue;
// Extract poster
const imgMatch = block.match(/<img\s[^>]*src=['"]([^'"]+)['"]/);
const image = imgMatch ? imgMatch[1]! : null;
// Extract title
const titleMatch = block.match(/<div class=['"]short-title['"]>([^<]+)<\/div>/);
if (!titleMatch) continue;
let titleRaw = titleMatch[1]!.trim();
// Extract link
const linkMatch = block.match(/<a class=['"]short-poster[^>]*href=['"]([^'"]+)['"]/);
let hrefPath = linkMatch ? linkMatch[1]! : null;
if (!hrefPath) continue;
// Remove domain if the link is absolute to keep paths source-agnostic
if (hrefPath.startsWith('http')) {
try {
const u = new URL(hrefPath);
hrefPath = u.pathname + u.search;
} catch { /* ignore */ }
}
let year: string | null = null;
const yearMatch = titleRaw.match(/\((\d{4})\)/);
if (yearMatch) {
year = yearMatch[1]!;
titleRaw = titleRaw.replace(/\s*\(\d{4}\)\s*/, '').trim();
}
if (titleRaw && hrefPath) {
results.push({ title: titleRaw, year, image, hrefPath, type: mediaType, source: 'fs24' });
}
}
return results;
}
/**
* Extrait le news_id depuis la page HTML (attribut data-news-id du bloc commu-releases-block).
*/
export function extractNewsId(html: string): string | null {
const match = html.match(/data-news-id="(\d+)"/);
return match ? match[1]! : null;
}
/**
* Décode un lien fsprotect double-Base64 en URL finale.
* Format: base64 → "url:<second_b64>|metadata|timestamp|hash"
* second_b64 → URL finale (ex: https://1fichier.com/...)
*/
export function decodeFsProtectLink(rawHref: string): string | null {
try {
// Extract the ?t= parameter
const tParamMatch = rawHref.match(/[?&]t=([^&]+)/);
if (!tParamMatch) return null;
const base64t = tParamMatch[1]!;
// First Base64 decode
const decodedT = Buffer.from(base64t, 'base64').toString('utf-8');
// Format: url:<second_base64>|<metadata>|<timestamp>|<hash>
if (!decodedT.startsWith('url:')) return null;
const firstPart = decodedT.substring(4).split('|')[0]!;
if (!firstPart) return null;
// Second Base64 decode → final URL
return Buffer.from(firstPart, 'base64').toString('utf-8');
} catch (e: any) {
console.error('[FS24] Erreur décodage lien Base64:', e.message);
return null;
}
}
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(2) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
}
/**
* Parse la réponse JSON de l'API release-api.php en VideoLink[].
*/
export function parseReleasesJSON(data: any): VideoLink[] {
const links: VideoLink[] = [];
if (!data || !data.ok || !Array.isArray(data.items)) return links;
for (const item of data.items) {
const rawLink = item.original_link || '';
const finalUrl = decodeFsProtectLink(rawLink);
if (!finalUrl) continue;
const releaseName = item.release_name || 'Inconnu';
const lowerName = releaseName.toLowerCase();
// Detect language from release name
const langs: string[] = [];
if (lowerName.includes('multi')) langs.push('vf', 'vostfr');
else if (lowerName.includes('vostfr')) langs.push('vostfr');
else if (lowerName.includes('truefrench') || lowerName.includes('french')) langs.push('vf');
else langs.push('vf');
// Detect host from URL
let host = 'Inconnu';
try {
const urlObj = new URL(finalUrl);
host = urlObj.hostname.replace('www.', '');
} catch { /* ignore */ }
links.push({
id: String(item.id),
host,
url: finalUrl,
quality: item.quality || '',
size: formatBytes(item.size_bytes),
releaseName: item.is_team ? `[TEAM] ${releaseName}` : releaseName,
langs
});
}
return links;
}
+195
View File
@@ -0,0 +1,195 @@
import { CONFIG } from '../../src/utils/config.js';
export const CONFIG_HYDRACKER = {
get BASE_URL() { return (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''); },
get API_KEY() { return CONFIG.HYDRACKER_API_KEY; },
get TIMEOUT() { return CONFIG.HYDRACKER_TIMEOUT || 15000; },
};
export function getHydrackerHeaders() {
return {
'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'
};
}
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(), CONFIG_HYDRACKER.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> = {}) {
let qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
// FIX: Hydracker API returns 401 if ':' is URL-encoded as '%3A'
qs = qs.replace(/%3A/g, ':');
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/${urlPath}` + (qs ? `?${qs}` : '');
try {
const res = await fetchWithRetry(url, {
headers: getHydrackerHeaders()
});
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: { ...getHydrackerHeaders(), '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/titles?query=${encodeURIComponent(query)}`;
try {
const res = await fetchWithRetry(url, {
headers: getHydrackerHeaders()
});
if (!res.ok) {
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
return null;
}
const data = await res.json();
// Transform the new API structure to match the old expected structure
if (data && data.pagination && Array.isArray(data.pagination.data)) {
return { results: data.pagination.data };
}
return data;
} 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: getHydrackerHeaders()
});
if (!res.ok) return null;
return await res.json();
} catch (e: any) {
return null;
}
}
/**
* Récupère la page de download d'un titre.
* - Films : GET /titles/{id}/download
* - Séries : GET /titles/{id}/season/{s}/episode/{e}/download
*
* Retourne l'objet complet contenant: video, alternative_videos, title.seasons, last_episode, etc.
*/
export async function fetchDownloadPage(titleId: string, season?: number, episode?: number) {
let urlPath: string;
if (season && season > 0 && episode && episode > 0) {
urlPath = `titles/${titleId}/season/${season}/episode/${episode}/download`;
} else if (season && season > 0) {
// On demande le premier épisode de la saison pour obtenir les métadonnées
urlPath = `titles/${titleId}/season/${season}/episode/1/download`;
} else {
urlPath = `titles/${titleId}/download`;
}
return await apiGet(urlPath);
}
/**
* Récupère TOUS les liens d'une saison en itérant sur chaque épisode via /download.
* Utilise last_episode pour savoir combien d'épisodes ont des liens.
*/
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
// D'abord, obtenir les métadonnées pour savoir combien d'épisodes il y a
const firstPage = await fetchDownloadPage(titleId, season, 1);
if (!firstPage) return [];
const lastEpisodeMap = firstPage.last_episode || {};
const lastEp = lastEpisodeMap[String(season)] || 0;
if (lastEp === 0) return [];
// Collecter les liens de tous les épisodes
const allLiens: any[] = [];
// Extraire les liens du premier épisode qu'on a déjà chargé
const extractLiens = (downloadData: any) => {
const liens: any[] = [];
if (downloadData.video) liens.push(downloadData.video);
if (downloadData.alternative_videos) {
for (const av of downloadData.alternative_videos) {
// Éviter les doublons (video est souvent dans alternative_videos aussi)
if (!liens.find(l => l.id === av.id)) {
liens.push(av);
}
}
}
return liens;
};
allLiens.push(...extractLiens(firstPage));
// Charger les épisodes suivants (2 à lastEp)
for (let ep = 2; ep <= lastEp; ep++) {
const epData = await fetchDownloadPage(titleId, season, ep);
if (epData) {
allLiens.push(...extractLiens(epData));
}
}
return allLiens;
}
+284
View File
@@ -0,0 +1,284 @@
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, fetchDownloadPage, 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> {
console.warn('[Hydracker] ⚠️ Plugin désactivé (Site fermé définitivement). Conservé pour archivage.');
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[]> {
// Channel 12 = Films, Channel 10 = Séries
const channelId = mediaType === 'series' ? 10 : 12;
try {
const data = await apiGet(`channel/${channelId}`, {
restriction: '',
order: 'trending:desc',
filters: '',
page: 1,
paginate: 'lengthAware',
returnContentOnly: true
});
return parseTrendingResults(data);
} catch (e: any) {
console.error(`[Hydracker] getTrending Error for channel ${channelId}:`, 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> {
// Récupérer les infos du titre via /download pour avoir les saisons
const titleData = await fetchDownloadPage(identifier);
let isSeries = false;
if (type) {
isSeries = (type === 'series' || type === 'serie' || type === 'tv');
} else if (titleData && titleData.title) {
isSeries = titleData.title.is_series === true;
}
// Extraire les saisons depuis la réponse /download
const seasonsList: number[] = [];
if (titleData && titleData.title && titleData.title.seasons) {
const seasons = titleData.title.seasons;
for (const s of seasons) {
if (typeof s.number === 'number' && s.number > 0) {
seasonsList.push(s.number);
}
}
seasonsList.sort((a, b) => a - b);
}
if (seasonsList.length > 0) isSeries = true;
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : (isSeries ? 1 : 0);
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> {
if (season === 0) {
// Film : utiliser /download directement
const downloadData = await fetchDownloadPage(titleId);
if (!downloadData) return { links: [] };
return { links: this.parseLiensFromDownload(downloadData, season) };
}
// Série : itérer sur les épisodes
const rawLiens = await fetchSeriesLiens(titleId, season);
const links: VideoLink[] = rawLiens.map(l => this.parseSingleLien(l, season));
return { links };
}
/**
* Parse les liens depuis une réponse /download (film ou épisode unique)
*/
private parseLiensFromDownload(downloadData: any, season: number): VideoLink[] {
const allLiens: any[] = [];
if (downloadData.video) allLiens.push(downloadData.video);
if (downloadData.alternative_videos) {
for (const av of downloadData.alternative_videos) {
if (!allLiens.find(l => l.id === av.id)) {
allLiens.push(av);
}
}
}
return allLiens.map(l => this.parseSingleLien(l, season));
}
/**
* Convertit un objet lien brut de l'API en VideoLink unifié
*/
private parseSingleLien(l: any, season: number): VideoLink {
// Extraire le nom du host
const hostName = l.host_compact?.name || l.host?.name || l.name || '?';
// Extraire la qualité
const quality = l.qual?.qual || l.quality || QUALITY_MAP[l.qualite] || `id:${l.qualite}`;
// Extraire les langues
const langs = l.langues
? l.langues.map((la: any) => la.lang || la.name || '')
: getLangs(l);
// Extraire les sous-titres
const subs = l.subs_compact
? l.subs_compact.map((s: any) => s.name || '')
: getSubs(l);
return {
id: l.id,
host: hostName,
size: formatSize(l.taille),
sizeBytes: l.taille || 0,
quality,
langs,
subs,
releaseName: l.release || l.filename || l.name || l.titre || l.titre_release || undefined,
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00" || l.episode === null)
? (season === 0 ? 'Film complet' : 'Saison complète')
: (l.episode ? String(l.episode) : null),
url: null
};
}
async getSeasons(titleId: string): Promise<number[]> {
// Utiliser /download pour récupérer les saisons (au lieu de /titles/{id} qui est redondant)
const downloadData = await fetchDownloadPage(titleId);
if (!downloadData || !downloadData.title || !downloadData.title.seasons) return [];
return downloadData.title.seasons
.map((s: any) => s.number)
.filter((n: any) => typeof n === 'number' && n > 0)
.sort((a: number, b: number) => a - b);
}
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;
}
}
// Tenter la résolution via l'API /content/liens/{id}
try {
const result = await apiGet(`content/liens/${linkId}`);
if (result && (result.directDL || result.url || result.link)) {
const finalUrl = result.directDL || result.url || result.link;
console.log(`[Hydracker] Got final URL via API: ${finalUrl.substring(0, 80)}...`);
return finalUrl;
}
// Vérifier aussi dans result.lien (format alternatif)
if (result && result.lien && result.lien.lien) {
console.log(`[Hydracker] Got final URL via result.lien.lien`);
return result.lien.lien;
}
} catch (e: any) {
console.error(`[Hydracker] Exception resolving lien ${linkId}:`, e.message);
}
console.log(`[Hydracker] Échec de la résolution API. Fallback automatique via Movix...`);
return await this.resolveMovixLink(linkId);
}
async resolveMovixLink(lienId: string, titleId?: string): Promise<string | null> {
try {
const { CONFIG } = await import('../../src/utils/config.js');
const movixBase = CONFIG.MOVIX_URL || '';
if (!movixBase) {
console.warn('[Hydracker] MOVIX_URL non configurée, impossible de résoudre via Movix.');
return null;
}
const movixApiBase = (() => {
try {
const u = new URL(movixBase);
return `${u.protocol}//api.${u.host}/api`;
} catch { return ''; }
})();
if (!movixApiBase) return null;
console.log(`[Hydracker] Tentative de débridage Movix pour le lien ${lienId}...`);
const url = `${movixApiBase}/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': `${movixBase}/`,
'Origin': movixBase,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 OPR/133.0.0.0'
}
});
const data = await response.json();
if (!response.ok || data.success === false) {
console.error('[Hydracker] Erreur API Movix:', data.error || 'Erreur inconnue');
return null;
}
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;
}
+165
View File
@@ -0,0 +1,165 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType } from '../../src/types/source.js';
import { CONFIG } from '../../src/utils/config.js';
export class LoadixSource implements ISource {
name = 'loadix';
displayName = 'Loadix';
private get frontUrl() { return (CONFIG.LOADIX_URL || '').replace(/\/+$/, ''); }
private get baseUrl() {
const urlObj = new URL(this.frontUrl);
return `https://api.${urlObj.host}/api`;
}
private tmdbImageBase = 'https://image.tmdb.org/t/p/w500';
private mapType(type: string): MediaType {
if (type === 'series') return 'series';
if (type === 'anime') return 'anime';
return 'movie';
}
private formatSearchResult(hit: any): SearchResult {
return {
title: hit.title,
year: hit.year ? hit.year.toString() : null,
image: hit.posterPath ? `${this.tmdbImageBase}${hit.posterPath}` : null,
hrefPath: `${this.frontUrl}/media/${hit.id}`,
type: this.mapType(hit.type),
source: this.name
};
}
async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/search?q=${encodeURIComponent(query)}&page=1&pageSize=30`;
const res = await fetch(url);
const data = await res.json();
let hits = data.hits || [];
if (mediaType && mediaType !== 'other') {
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
}
return hits.map((h: any) => this.formatSearchResult(h));
}
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/search?q=&page=1&pageSize=30&sort=click_count_desc`;
const res = await fetch(url);
const data = await res.json();
let hits = data.hits || [];
if (mediaType && mediaType !== 'other') {
hits = hits.filter((h: any) => this.mapType(h.type) === mediaType);
}
return hits.map((h: any) => this.formatSearchResult(h));
}
async getRecent(): Promise<SearchResult[]> {
const url = `${this.baseUrl}/media/recent?limit=24`;
const res = await fetch(url);
const data = await res.json();
const items = data.items || [];
return items.map((h: any) => this.formatSearchResult(h));
}
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const idMatch = identifier.match(/media\/([a-f0-9\-]+)/);
if (!idMatch) throw new Error("URL Loadix invalide.");
const mediaId = idMatch[1];
// Fetch links
const url = `${this.baseUrl}/media/${mediaId}/links?page=1&perPage=100&sort=scope_asc`;
const res = await fetch(url);
const data = await res.json();
const items = data.items || [];
const links = items.map((item: any) => {
let episode = null;
if (item.scope === 'season' && item.seasonNumber) {
episode = `S${String(item.seasonNumber).padStart(2, '0')}`;
} else if (item.scope === 'episode' && item.seasonNumber && item.episodeNumber) {
episode = `S${String(item.seasonNumber).padStart(2, '0')}E${String(item.episodeNumber).padStart(2, '0')}`;
}
return {
id: `${identifier}|${item.id}`,
host: item.provider || 'unknown',
quality: item.quality,
langs: item.language ? [item.language] : [],
sizeBytes: item.sizeBytes ? parseInt(item.sizeBytes) : undefined,
size: item.sizeHuman,
releaseName: item.releaseGroup,
episode: episode,
url: null // Protected by Turnstile, resolved later by direct redirect
};
});
// Check if there are any episodes/seasons to determine if it's a series
const isSeries = links.some((l: any) => l.episode);
// Extract seasons (just based on found links)
const seasonsMap = new Map<string, string>();
if (isSeries) {
items.forEach((item: any) => {
if (item.seasonNumber) {
const seasonStr = `Saison ${item.seasonNumber}`;
seasonsMap.set(String(item.seasonNumber), seasonStr);
}
});
}
const seasons = Array.from(seasonsMap.entries()).map(([val, label]) => ({
value: val,
label: label
}));
return {
links,
seasons,
isSeries
};
}
async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
const selection = await this.getSelection(identifier);
let links = selection.links;
if (season) {
const seasonPrefix = `S${String(season).padStart(2, '0')}`;
links = links.filter(l => l.episode && l.episode.startsWith(seasonPrefix));
}
return { links };
}
async healthCheck(): Promise<boolean> {
if (!this.frontUrl) {
console.warn('[Loadix] ⚠️ LOADIX_URL non définie.');
return false;
}
try {
const results = await this.getRecent();
return results.length > 0;
} catch (e: any) {
console.error(`[Loadix] Healthcheck failed: ${e.message}`);
return false;
}
}
async resolveLink(linkId: string, extraData?: any): Promise<any> {
const [url] = linkId.split('|');
// Like Flixart, Turnstile cannot be solved on localhost.
// We directly return the manual redirection challenge to open Loadix.
return {
captcha: 'turnstile',
url: url,
sourceName: 'Loadix'
};
}
}
// Auto-registration
import { sourceRegistry } from '../../src/core/registry.js';
sourceRegistry.register(new LoadixSource());
+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());
+47
View File
@@ -0,0 +1,47 @@
import { CONFIG } from '../../src/utils/config.js';
export class MovixAPI {
private static get baseUrl(): string {
return CONFIG.MOVIX_URL || '';
}
private static get apiUrl(): string {
if (!this.baseUrl) return '';
try {
const url = new URL(this.baseUrl);
return `${url.protocol}//api.${url.host}/api`;
} catch {
return '';
}
}
private static getHeaders() {
return {
'Accept': 'application/json, text/plain, */*',
'Origin': this.baseUrl,
'Referer': `${this.baseUrl}/`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 OPR/133.0.0.0'
};
}
public static async search(query: string): Promise<any> {
const url = `${this.apiUrl}/search?title=${encodeURIComponent(query)}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Search HTTP ${res.status}`);
return res.json();
}
public static async getDownloadLinks(type: string, id: number | string, tmdbId: number | string): Promise<any> {
const url = `${this.apiUrl}/darkiworld/download/${type}/${id}?tmdbId=${tmdbId}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Download HTTP ${res.status}`);
return res.json();
}
public static async decodeLink(linkId: string | number, titleId: string | number): Promise<any> {
const url = `${this.apiUrl}/darkiworld/decode/${linkId}?title_id=${titleId}`;
const res = await fetch(url, { headers: this.getHeaders() });
if (!res.ok) throw new Error(`Movix Decode HTTP ${res.status}`);
return res.json();
}
}
+207
View File
@@ -0,0 +1,207 @@
import { ISource, SearchResult, SelectionData, ContentLinks, MediaType, VideoLink } from '../../src/types/source.js';
import { sourceRegistry } from '../../src/core/registry.js';
import { MovixAPI } from './api.js';
import { CONFIG } from '../../src/utils/config.js';
class MovixSource implements ISource {
public readonly name = 'movix';
public readonly displayName = 'Movix';
public async healthCheck(): Promise<boolean> {
if (!CONFIG.MOVIX_URL) return false;
try {
// A quick check to see if the search endpoint is reachable
await MovixAPI.search('test');
return true;
} catch (e) {
console.error(`[MOVIX] Health check failed:`, e);
return false;
}
}
public async search(query: string, mediaType?: MediaType): Promise<SearchResult[]> {
try {
const response = await MovixAPI.search(query);
if (!response || !response.results) return [];
const results: SearchResult[] = [];
const searchLower = query.toLowerCase().trim();
for (const item of response.results) {
if (!item.name) continue;
// Filtre optionnel pour aligner les résultats avec la recherche
const nameLower = item.name.toLowerCase();
const originalLower = item.original_title ? item.original_title.toLowerCase() : '';
// On vérifie si la requête est incluse dans le titre ou le titre original
if (!nameLower.includes(searchLower) && !originalLower.includes(searchLower)) {
// Pour être un peu plus permissif, on vérifie si tous les mots clés y sont
const words = searchLower.split(' ');
const allWordsMatch = words.every(w => nameLower.includes(w) || originalLower.includes(w));
if (!allWordsMatch) continue;
}
// Filtrage basique par mediaType si fourni
if (mediaType) {
if (mediaType === 'movie' && item.type !== 'movie') continue;
if (mediaType === 'series' && item.type !== 'serie') continue; // Verify if it's 'serie' or 'series'
}
const hrefPath = `movix:${item.id}:${item.tmdb_id || 0}:${item.type}`;
let image = null;
if (item.poster) {
image = item.poster.startsWith('http') ? item.poster : `https://image.tmdb.org/t/p/w300/${item.poster}`;
}
results.push({
title: item.name,
year: item.year ? item.year.toString() : null,
image,
hrefPath,
type: item.type === 'movie' ? 'movie' : (item.type === 'serie' || item.type === 'series' ? 'series' : 'other'),
source: this.name
});
}
return results;
} catch (e) {
console.error(`[MOVIX] Search error:`, e);
return [];
}
}
public async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
const typeStr = mediaType === 'series' ? 'tv' : 'movie';
const url = `https://api.themoviedb.org/3/trending/${typeStr}/day?api_key=f3d757824f08ea2cff45eb8f47ca3a1e&language=fr-FR`;
try {
const res = await fetch(url);
const data = await res.json();
if (!data || !data.results) return [];
return data.results.map((item: any) => {
const title = item.title || item.name;
const year = item.release_date ? item.release_date.split('-')[0] : (item.first_air_date ? item.first_air_date.split('-')[0] : null);
const image = item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : null;
const tmdbId = item.id;
const type = mediaType === 'series' ? 'series' : 'movie';
// Identifiant spécial pour faire la recherche au moment du clic
const hrefPath = `movix:tmdb:${tmdbId}:${type}:${encodeURIComponent(title)}`;
return {
title,
year,
image,
hrefPath,
type,
source: this.name
};
});
} catch(e) {
console.error(`[MOVIX] TMDB Trending error:`, e);
return [];
}
}
public async getRecent(): Promise<SearchResult[]> {
// Fallback on movies trending as recent if no specific endpoint
return this.getTrending('movie');
}
public async getContentLinks(identifier: string, season?: number): Promise<ContentLinks> {
try {
const parts = identifier.split(':');
if (parts.length < 4) return { links: [] };
let id: string, tmdbId: string, type: string;
if (parts[1] === 'tmdb') {
tmdbId = parts[2];
type = parts[3];
const title = decodeURIComponent(parts.slice(4).join(':'));
// Recherche sur Movix pour récupérer l'ID interne
const searchRes = await MovixAPI.search(title);
const item = searchRes.results?.find((r: any) => String(r.tmdb_id) === String(tmdbId) || r.name === title);
if (!item) {
console.log(`[MOVIX] TMDB item not found on Movix: ${title}`);
return { links: [] };
}
id = item.id;
// Update type depending on what Movix returned
type = item.type === 'serie' || item.type === 'series' ? 'series' : 'movie';
} else {
id = parts[1];
tmdbId = parts[2];
type = parts[3];
}
const data = await MovixAPI.getDownloadLinks(type, id, tmdbId);
const links: VideoLink[] = [];
if (data && data.data) {
// Pour chaque host (1fichier, etc)
for (const item of data.data) {
if (!item.links || !Array.isArray(item.links)) continue;
const host = item.host || 'unknown';
const quality = item.qualite || 'Unknown';
const lang = item.langue || 'Unknown';
const size = item.size || '';
for (const linkObj of item.links) {
const linkId = linkObj.id;
if (!linkId) continue;
links.push({
id: `${linkId}|${id}`, // Store both linkId and titleId
host: host,
label: `${quality} - ${lang}`,
url: null, // Resolves later
size: size,
quality: quality,
langs: [lang]
});
}
}
}
return { links };
} catch (e) {
console.error(`[MOVIX] Error in getContentLinks:`, e);
return { links: [] };
}
}
public async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
const content = await this.getContentLinks(identifier);
return {
links: content.links,
seasons: [],
isSeries: type === 'series'
};
}
public async resolveLink(combinedId: string): Promise<string | null> {
try {
const [linkId, titleId] = combinedId.split('|');
if (!linkId || !titleId) return null;
const res = await MovixAPI.decodeLink(linkId, titleId);
if (res && res.url) {
return res.url;
}
return null;
} catch (e) {
console.error(`[MOVIX] ResolveLink error for ${combinedId}:`, e);
return null;
}
}
}
sourceRegistry.register(new MovixSource());
+37
View File
@@ -0,0 +1,37 @@
/**
* Appels réseau pour zone-telechargement.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);
}
+133
View File
@@ -0,0 +1,133 @@
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 = 'ztnews';
displayName = 'Zone-Téléchargement (Team)';
get baseUrl() {
return CONFIG.ZTTEAM_URL?.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());
+175
View File
@@ -0,0 +1,175 @@
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;
let year: string | null = null;
const yearMatch = title.match(/\(\s*(\d{4})\s*\)/) || href.match(/-(\d{4})-/);
if (yearMatch) {
year = yearMatch[1];
}
results.push({
title,
year,
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;
}