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
+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;
}