Files
FanKarr/server/lib/serie-helpers.ts
T

257 lines
12 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(`Fankai API ${res.status}: ${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)
// Données scraper avec le titre à jour de l'API, 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`), fankaiGet(`/series/${serieId}`)])
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value))
return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
try {
const sd = await fetchSerieFromApi(serieId)
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return sd
} catch (err) {
logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
return null
}
}
let lastCatalogWarn = 0
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
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
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
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,
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
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 une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
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>): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
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 allTorrents) {
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)
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
return 'partial'
}