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