v1.5.9 (Actuel)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user