166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
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());
|