270 lines
13 KiB
TypeScript
270 lines
13 KiB
TypeScript
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<any> {
|
|
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<any> {
|
|
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<number>()
|
|
for (const season of sd.seasons ?? [])
|
|
for (const ep of season.episodes ?? []) {
|
|
known.add(ep.id)
|
|
for (const v of Object.values<any>(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<any | null> {
|
|
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<number, string>(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<any[]> {
|
|
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<number, any>()
|
|
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<string, Record<string, any>>,
|
|
activeTorrents: Set<string>,
|
|
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<string, Record<string, any>>): { total: number; organized: number } {
|
|
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
|
|
const uniqueEpKeys = new Set<string>()
|
|
const epIdToKey = new Map<number, string>()
|
|
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<string>()
|
|
|
|
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 }
|
|
}
|