v1.5.9 (Actuel)
This commit is contained in:
+5
-7
@@ -30,13 +30,11 @@ function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
||||
});
|
||||
}
|
||||
|
||||
export class ZTAPI implements ISource {
|
||||
export class ZoneTelechargementAPI implements ISource {
|
||||
name = 'zt';
|
||||
displayName = 'ZT';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
displayName = 'Zone-Téléchargement';
|
||||
get baseUrl() {
|
||||
return CONFIG.ZT_URL?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
@@ -161,4 +159,4 @@ export class ZTAPI implements ISource {
|
||||
}
|
||||
|
||||
// ── Auto-registration ──
|
||||
sourceRegistry.register(new ZTAPI(CONFIG.ZT_URL));
|
||||
sourceRegistry.register(new ZoneTelechargementAPI());
|
||||
|
||||
@@ -32,7 +32,13 @@ export function parseSearchHTML(html: string, baseUrl: string | undefined): Sear
|
||||
type = 'anime';
|
||||
}
|
||||
|
||||
results.push({ title, image, hrefPath: href, year: null, type, source: 'zt' });
|
||||
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;
|
||||
@@ -80,7 +86,7 @@ export function parseContentHTML(html: string): ContentLinks {
|
||||
const cleanedLabel = label.replace(sizeRegex, "").trim();
|
||||
|
||||
// On n'utilise le label comme "épisode" que si c'est un vrai nom de fichier/épisode (pas juste "Télécharger")
|
||||
const isGenericLabel = /^(t\u00e9l\u00e9charger|download|cliquez ici|lien|turbobit|1fichier|uptobox|rapidgator|nitroflare|send.now)/i.test(cleanedLabel);
|
||||
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")
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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 };
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,8 @@ function isSeriesIdentifier(identifier: string): boolean {
|
||||
export class FreeTeleAPI implements ISource {
|
||||
name = 'freetel';
|
||||
displayName = 'Free-Télécharger';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
||||
get baseUrl() {
|
||||
return CONFIG.FT_URL?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
@@ -114,7 +112,7 @@ export class FreeTeleAPI implements ISource {
|
||||
let hostUrl: string | null = null;
|
||||
|
||||
// Cas série : page intermédiaire liens.free-telecharger.cam/SLUG-episode_N
|
||||
if (linkId.includes('liens.free-telecharger.cam')) {
|
||||
if (linkId.includes('liens.free-telecharger.')) {
|
||||
try {
|
||||
const html = await fetchPage(linkId);
|
||||
const hosts = parseEpisodeLinks(html);
|
||||
@@ -139,4 +137,4 @@ export class FreeTeleAPI implements ISource {
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new FreeTeleAPI(CONFIG.FT_URL));
|
||||
sourceRegistry.register(new FreeTeleAPI());
|
||||
|
||||
@@ -64,9 +64,17 @@ function detectType(href: string): 'movie' | 'series' | 'anime' {
|
||||
}
|
||||
|
||||
function absUrl(url: string, baseUrl: string): string {
|
||||
if (url.startsWith('http')) return url;
|
||||
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 + '/' + url.replace(/^\//, '');
|
||||
return cleanedBase + '/' + path.replace(/^\//, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,9 +91,15 @@ export function parseSearchResults(html: string, baseUrl: string): SearchResult[
|
||||
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: null,
|
||||
year,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(hrefRaw),
|
||||
@@ -106,9 +120,15 @@ export function parseTrendingResults(html: string, baseUrl: string): SearchResul
|
||||
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: null,
|
||||
year,
|
||||
image,
|
||||
hrefPath: absUrl(hrefRaw, baseUrl),
|
||||
type: detectType(hrefRaw),
|
||||
@@ -127,7 +147,7 @@ export function parseContentHTML(html: string, isSeries: boolean): ContentLinks
|
||||
const links: VideoLink[] = [];
|
||||
|
||||
if (isSeries) {
|
||||
const episodeRegex = /<input[^>]+name="lien"\s+value="(https?:\/\/liens\.free-telecharger\.cam\/[^"]+)"/gi;
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
}
|
||||
+86
-34
@@ -1,18 +1,18 @@
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
|
||||
export const CONFIG_HYDRACKER = {
|
||||
BASE_URL: (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''), // Supprime le slash final
|
||||
API_KEY: CONFIG.HYDRACKER_API_KEY,
|
||||
TIMEOUT: CONFIG.HYDRACKER_TIMEOUT || 15000,
|
||||
get BASE_URL() { return (CONFIG.HYDRACKER_URL || '').replace(/\/$/, ''); },
|
||||
get API_KEY() { return CONFIG.HYDRACKER_API_KEY; },
|
||||
get TIMEOUT() { return CONFIG.HYDRACKER_TIMEOUT || 15000; },
|
||||
};
|
||||
|
||||
const HYDRACKER_HEADERS = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${CONFIG_HYDRACKER.API_KEY}`,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||
};
|
||||
|
||||
const TIMEOUT = CONFIG_HYDRACKER.TIMEOUT; // 30 secondes par défaut (configurable)
|
||||
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,
|
||||
@@ -26,7 +26,7 @@ async function fetchWithRetry(
|
||||
while (true) {
|
||||
attempt++;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG_HYDRACKER.TIMEOUT);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
@@ -60,11 +60,13 @@ async function fetchWithRetry(
|
||||
}
|
||||
|
||||
export async function apiGet(urlPath: string, params: Record<string, any> = {}) {
|
||||
const qs = Object.entries(params).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
||||
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: HYDRACKER_HEADERS
|
||||
headers: getHydrackerHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[Hydracker-API] apiGet HTTP ${res.status} on ${urlPath}`);
|
||||
@@ -82,7 +84,7 @@ export async function apiPost(urlPath: string, body: any = {}) {
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
method: 'POST',
|
||||
headers: { ...HYDRACKER_HEADERS, 'Content-Type': 'application/json' },
|
||||
headers: { ...getHydrackerHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
@@ -93,16 +95,21 @@ export async function apiPost(urlPath: string, body: any = {}) {
|
||||
}
|
||||
|
||||
export async function fetchSearch(query: string) {
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/search/${encodeURIComponent(query)}?loader=searchAutocomplete`;
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles?query=${encodeURIComponent(query)}`;
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
headers: HYDRACKER_HEADERS
|
||||
headers: getHydrackerHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[Hydracker-API] Search HTTP ${res.status} for "${query}"`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
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;
|
||||
@@ -113,7 +120,7 @@ export async function fetchMovieLinks(titleId: string) {
|
||||
const url = `${CONFIG_HYDRACKER.BASE_URL}/api/v1/titles/${titleId}/download`;
|
||||
try {
|
||||
const res = await fetchWithRetry(url, {
|
||||
headers: HYDRACKER_HEADERS
|
||||
headers: getHydrackerHeaders()
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
@@ -122,22 +129,67 @@ export async function fetchMovieLinks(titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSeriesLiens(titleId: string, season: number = 1) {
|
||||
const allLiens: any[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await apiGet('liens', {
|
||||
title_id: titleId, loader: 'linksdl', season,
|
||||
perPage: 500, page, filters: '', paginate: 'lengthAware'
|
||||
});
|
||||
if (!result || result.error) break;
|
||||
const pagination = result.pagination || {};
|
||||
const data = pagination.data || [];
|
||||
if (!data.length) break;
|
||||
allLiens.push(...data);
|
||||
const lastPage = pagination.last_page || pagination.lastPage || 1;
|
||||
if (page >= lastPage) break;
|
||||
page++;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
+135
-76
@@ -1,6 +1,6 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, VideoLink, SelectionData } from '../../src/types/source.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchMovieLinks, fetchSeriesLiens } from './api.js';
|
||||
import { CONFIG_HYDRACKER, apiGet, apiPost, fetchSearch, fetchDownloadPage, fetchSeriesLiens } from './api.js';
|
||||
import {
|
||||
QUALITY_MAP, formatSize,
|
||||
parseSearchResults, parseTrendingResults,
|
||||
@@ -13,19 +13,8 @@ export class HydrackerAPI implements ISource {
|
||||
displayName = 'Hydracker (Token)';
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!CONFIG_HYDRACKER.BASE_URL || !CONFIG_HYDRACKER.API_KEY) {
|
||||
console.warn('[Hydracker] ⚠️ HYDRACKER_URL ou HYDRACKER_API_KEY manquante.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(CONFIG_HYDRACKER.BASE_URL, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' },
|
||||
signal: AbortSignal.timeout(CONFIG_HYDRACKER.TIMEOUT)
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
console.warn('[Hydracker] ⚠️ Plugin désactivé (Site fermé définitivement). Conservé pour archivage.');
|
||||
return false;
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: MediaType = 'movie'): Promise<SearchResult[]> {
|
||||
@@ -41,12 +30,20 @@ export class HydrackerAPI implements ISource {
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
const type = mediaType === 'series' ? 'series' : 'movie';
|
||||
// Channel 12 = Films, Channel 10 = Séries
|
||||
const channelId = mediaType === 'series' ? 10 : 12;
|
||||
try {
|
||||
const data = await apiGet('titles', { order: 'trending:desc', type, page: 1, paginate: 'lengthAware' });
|
||||
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 ${type}:`, e.message);
|
||||
console.error(`[Hydracker] getTrending Error for channel ${channelId}:`, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -62,16 +59,31 @@ export class HydrackerAPI implements ISource {
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
const seasonsList = await this.getSeasons(identifier);
|
||||
|
||||
// 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 {
|
||||
isSeries = seasonsList.length > 0;
|
||||
} else if (titleData && titleData.title) {
|
||||
isSeries = titleData.title.is_series === true;
|
||||
}
|
||||
|
||||
const currentSeason = seasonValue ? parseInt(String(seasonValue), 10) : 1;
|
||||
// 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 }));
|
||||
|
||||
@@ -83,36 +95,80 @@ export class HydrackerAPI implements ISource {
|
||||
}
|
||||
|
||||
async getContentLinks(titleId: string, season: number = 1): Promise<ContentLinks> {
|
||||
// Essai film en premier
|
||||
const movieData = await fetchMovieLinks(titleId);
|
||||
if (movieData) {
|
||||
const movieLinks = parseMovieLinks(movieData);
|
||||
if (movieLinks.length > 0) return { links: movieLinks };
|
||||
if (season === 0) {
|
||||
// Film : utiliser /download directement
|
||||
const downloadData = await fetchDownloadPage(titleId);
|
||||
if (!downloadData) return { links: [] };
|
||||
return { links: this.parseLiensFromDownload(downloadData, season) };
|
||||
}
|
||||
|
||||
// Fallback série
|
||||
// Série : itérer sur les épisodes
|
||||
const rawLiens = await fetchSeriesLiens(titleId, season);
|
||||
const links: VideoLink[] = rawLiens.map(l => ({
|
||||
id: l.id,
|
||||
host: (l.host && l.host.name) || '?',
|
||||
size: formatSize(l.taille),
|
||||
sizeBytes: l.taille || 0,
|
||||
quality: QUALITY_MAP[l.qualite] || `id:${l.qualite}`,
|
||||
langs: getLangs(l),
|
||||
subs: getSubs(l),
|
||||
releaseName: l.release || l.name || l.titre || l.titre_release || undefined,
|
||||
episode: (l.episode === 0 || l.episode === "0" || l.episode === "00")
|
||||
? 'Saison complète'
|
||||
: (l.episode ? String(l.episode) : null),
|
||||
url: null
|
||||
}));
|
||||
|
||||
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[]> {
|
||||
const result = await apiGet(`titles/${titleId}/seasons`);
|
||||
return parseSeasons(result);
|
||||
// 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;
|
||||
@@ -150,50 +206,54 @@ export class HydrackerAPI implements ISource {
|
||||
}
|
||||
}
|
||||
|
||||
const isPremium = await this.checkPremiumStatus();
|
||||
|
||||
if (!isPremium) {
|
||||
console.log(`[Hydracker] Compte non Premium détecté. Bypass de Hydracker, passage direct à Movix...`);
|
||||
return await this.resolveMovixLink(linkId);
|
||||
}
|
||||
|
||||
const maxRetries = 4;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
console.log(`[Hydracker] Retry ${attempt}/${maxRetries} for lien ${linkId}`);
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
}
|
||||
|
||||
const result = await apiGet(`content/liens/${linkId}`);
|
||||
if (!result) continue;
|
||||
|
||||
const finalUrl = result.directDL || result.url || result.link || '';
|
||||
if (!finalUrl) continue;
|
||||
|
||||
console.log(`[Hydracker] Got final URL: ${finalUrl.substring(0, 80)}...`);
|
||||
|
||||
// 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;
|
||||
} catch (e: any) {
|
||||
console.error(`[Hydracker] Exception resolving lien ${linkId} (attempt ${attempt}):`, e.message);
|
||||
}
|
||||
// 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 classique (Erreur). Fallback automatique via Movix...`);
|
||||
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 = `https://api.movix.cloud/api/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
|
||||
const url = `${movixApiBase}/darkiworld/decode/${lienId}${titleId ? `?title_id=${titleId}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Referer': 'https://movix.cloud/',
|
||||
'Origin': 'https://movix.cloud',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
'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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,7 +264,6 @@ export class HydrackerAPI implements ISource {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Récupération du lien direct selon le format de réponse Movix
|
||||
const directUrl = data.directDL || data.direct_url ||
|
||||
(data.embed_url && (data.embed_url.directDL || data.embed_url.src || data.embed_url.lien));
|
||||
|
||||
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Appels réseau pour zt.news.
|
||||
* Appels réseau pour zone-telechargement.news.
|
||||
* Pas de challenge CF actif, fetch direct simple.
|
||||
*/
|
||||
|
||||
|
||||
@@ -29,12 +29,10 @@ function deduplicateByTitle(results: SearchResult[]): SearchResult[] {
|
||||
}
|
||||
|
||||
export class ZtTeamAPI implements ISource {
|
||||
name = 'ztteam';
|
||||
displayName = 'ZT (Team)';
|
||||
private baseUrl: string | undefined;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl?.replace(/\/$/, '');
|
||||
name = 'ztnews';
|
||||
displayName = 'Zone-Téléchargement (Team)';
|
||||
get baseUrl() {
|
||||
return CONFIG.ZTTEAM_URL?.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
@@ -132,4 +130,4 @@ export class ZtTeamAPI implements ISource {
|
||||
}
|
||||
}
|
||||
|
||||
sourceRegistry.register(new ZtTeamAPI(CONFIG.ZTTEAM_URL));
|
||||
sourceRegistry.register(new ZtTeamAPI());
|
||||
|
||||
@@ -69,9 +69,15 @@ export function parseListingHTML(html: string, baseUrl: string): SearchResult[]
|
||||
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: null,
|
||||
year,
|
||||
image,
|
||||
hrefPath: href,
|
||||
type: detectType(titleMatch[1]!),
|
||||
|
||||
Reference in New Issue
Block a user