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