import path from 'path' import { logger } from '../logger.js' import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js' const FANKAI_API = 'https://metadata.fankai.fr' export async function fankaiGet(endpoint: string): Promise { const res = await fetch(`${FANKAI_API}${endpoint}`) if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`) return res.json() } export async function fetchSerieFromApi(serieId: number): Promise { const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${serieId}`), fankaiGet(`/series/${serieId}/seasons`)]) const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) return { ...serie, seasons: await Promise.all(seasons.map(async (season: any) => { const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) } })), } } export function serieHasEpisodes(sd: any): boolean { return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0) } export function serieFolderName(title: string): string { return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() } // Même règle que seasonFolder() du worker export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string { return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`) } const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err) // Le scraper écarte les épisodes qu'il n'associe à aucun fichier de torrent : on les reprend de l'API, sans torrent function addApiEpisodes(sd: any, api: any): number { const known = new Set() for (const season of sd.seasons ?? []) for (const ep of season.episodes ?? []) { known.add(ep.id) for (const v of Object.values(ep.codec_variants ?? {})) known.add(v.id) } let added = 0 for (const apiSeason of api.seasons ?? []) { const eps = (apiSeason.episodes ?? []).filter((ep: any) => !known.has(ep.id)) if (eps.length === 0) continue let season = sd.seasons.find((s: any) => s.id === apiSeason.id) if (!season) sd.seasons.push(season = { ...apiSeason, torrents: [], episodes: [] }) season.episodes.push(...eps.map((ep: any) => ({ ...ep, torrents: [], paths: [] }))) season.episodes.sort((a: any, b: any) => a.episode_number - b.episode_number) added += eps.length } sd.seasons.sort((a: any, b: any) => a.season_number - b.season_number) return added } // Données scraper complétées par l'API (titre, épisodes manquants), ou repli complet sur l'API quand le scraper n'a pas (encore) la série export async function resolveSerieData(serieId: number, action?: string): Promise { const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fetchSerieFromApi(serieId)]) if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value)) { if (api.status !== 'fulfilled') return scraper.value const sd = { ...structuredClone(scraper.value), title: api.value.title ?? scraper.value.title } const added = addApiEpisodes(sd, api.value) if (added > 0 && action) logger.warn('api', `${action} : ${added} épisode(s) de la série ${serieId} absent(s) du scraper, repris de l'API Fankai`) return sd } const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode' if (api.status === 'rejected') { logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(api.reason)})`) return null } if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`) return api.value } let lastCatalogWarn = 0 // Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors) export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> { const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)]) const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length let apiSeries: any[] try { const data = await fankaiGet('/series') apiSeries = Array.isArray(data) ? data : (data.series ?? []) } catch (err) { if (Date.now() - lastCatalogWarn > 60 * 60_000) { lastCatalogWarn = Date.now() logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`) } return { series: seriesData, complete: false } } const titles = new Map(apiSeries.filter(s => s.title).map(s => [s.id, s.title])) const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd) // Scraper vide = injoignable : ne pas interroger l'API série par série if (seriesData.length === 0) return { series: catalog, complete: false } const known = new Set(seriesData.map(sd => sd.id)) const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null))) return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) } } export async function loadCatalog(force = false): Promise { return (await loadCatalogStatus(force)).series } export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { if (!url) return null const stripped = url.replace(/^https?:\/\//, '') return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}` } export function normalizeSerie(serie: any): any { return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) } } export function normalizeSeason(season: any): any { return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) } } export function normalizeEpisode(ep: any): any { return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) } } export function extractTorrentsFromSerieData(sd: any): any[] { const result: any[] = [] if (!sd) return result for (const t of sd.torrents ?? []) { result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) }) } for (const season of sd.seasons ?? []) { for (const t of season.torrents ?? []) { result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) }) } for (const ep of season.episodes ?? []) { for (const t of ep.torrents ?? []) { result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) }) } } } return result } export function resolveEpNaming(ep: any, hash?: string | null): { formatted_name : string | null nfo_filename : string | null original_filename: string | null } { const h = hash?.toLowerCase() ?? null const pathEntry = h ? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h) : null return { formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null, nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null, original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null, } } export function computeExpectedName( ep : any, orgEntry: any, hash : string | null, nfoSupport: boolean, ): { currentName: string; expectedName: string; needsRename: boolean } { const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename const srcExt = path.extname(currentName) const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash) const expectedName = nfoSupport ? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName) : (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName) return { currentName, expectedName, needsRename: expectedName !== currentName } } /** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */ export function deduplicateEpisodes(episodes: any[]): any[] { const map = new Map() for (const ep of episodes) { const key = ep.episode_number if (!map.has(key)) { map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] }) } else { const primary = map.get(key)! const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean)) for (const t of ep.torrents ?? []) { if (!t.infohash || !seenHashes.has(t.infohash)) { primary.torrents.push(t) if (t.infohash) seenHashes.add(t.infohash) } } primary.torrent = primary.torrents[0] ?? null primary.available = primary.available || ep.available primary.organized = primary.organized || ep.organized } } return Array.from(map.values()) } export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] { const resolved: any[] = [] const h = hash.toLowerCase() for (const season of sd.seasons ?? []) { if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue for (const ep of season.episodes ?? []) { const paths: any[] = ep.paths ?? [] let filePath: string | null = null for (const p of paths) { if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') } else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break } } if (!filePath) continue const filename = filePath.split('/').pop() ?? filePath resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename }) } } return resolved } export function computeSerieDownloadState( serieData : any | null, organized : Record>, activeTorrents: Set, counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }, ): 'none' | 'downloading' | 'partial' | 'complete' { if (!serieData) return 'none' if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading' if (counts.total === 0 || counts.organized === 0) return 'none' if (counts.organized >= counts.total) return 'complete' return 'partial' } export function countOrganizedEpisodes(serieData: any, organized: Record>): { total: number; organized: number } { // Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un const uniqueEpKeys = new Set() const epIdToKey = new Map() for (const season of serieData.seasons ?? []) { for (const ep of season.episodes ?? []) { const key = `${season.season_number}:${ep.episode_number}` uniqueEpKeys.add(key) epIdToKey.set(ep.id, key) } } const organizedEpKeys = new Set() for (const t of extractTorrentsFromSerieData(serieData)) { const hash = t.infohash?.toLowerCase() if (!hash) continue const orgFiles = organized[hash] ?? {} if (Object.keys(orgFiles).length === 0) continue for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) { const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined if (isOrganized) { const key = epIdToKey.get(ep.episode_id) if (key) organizedEpKeys.add(key) } } } const trackedIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps))) for (const [id, key] of epIdToKey) if (trackedIds.has(String(id))) organizedEpKeys.add(key) return { total: uniqueEpKeys.size, organized: organizedEpKeys.size } }