From a4607c0e2f3f4987932ffd89120ff0117c77af55 Mon Sep 17 00:00:00 2001 From: ElPouki <61667226+Nackophilz@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:02:17 +0200 Subject: [PATCH] fix : ajout robustesse pour fallback scraper --- server/lib/serie-helpers.ts | 45 +++++++++++++++++++++++++-------- server/routes/import.ts | 50 +++++++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index 64f334d..28768dd 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -37,20 +37,45 @@ export function seasonFolderName(seasonNumber: number, englishDirectory: boolean 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 +// 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`), 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`) + 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 - } catch (err) { - logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(err)})`) + } + 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 diff --git a/server/routes/import.ts b/server/routes/import.ts index 4a94457..b75ebdc 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -7,7 +7,7 @@ import { readSettings } from '../settings.js' import { getGitlabTitle } from '../gitlab-map.js' import { resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus, - serieFolderName, seasonFolderName, + serieFolderName, seasonFolderName, fetchSerieFromApi, } from '../lib/serie-helpers.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { dispatchRemove } from '../torrent-clients/index.js' @@ -41,6 +41,24 @@ function findOrphans(organized: Organized, catalog: any[]) { return orphans } +// Épisodes publiés sur l'API mais écartés par le scraper : pas des orphelins. null si l'API ne répond pas +async function apiOnlyEpisodes(orphans: ReturnType, catalog: any[], mediaPath: string): Promise | null> { + const idByFolder = new Map(catalog.map(sd => [serieFolderName(sd.title ?? sd.show_title ?? ''), sd.id])) + const serieIds = new Set() + for (const o of orphans) { + if (!o.dest_path || !mediaPath) continue + const id = idByFolder.get(path.relative(mediaPath, o.dest_path).split(path.sep)[0]) + if (id != null) serieIds.add(id) + } + try { + const series = await Promise.all([...serieIds].map(fetchSerieFromApi)) + return new Set(series.flatMap(sd => (sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).map((ep: any) => String(ep.id))))) + } catch (err) { + logger.warn('api', `Épisodes disparus du catalogue : vérification sur l'API Fankai impossible (${err instanceof Error ? err.message : err})`) + return null + } +} + // Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série function serieFolders(sd: any, organized: Organized, mediaPath: string, exclude = new Set()): Map { const ids = new Set() @@ -105,11 +123,15 @@ router.post('/manual-import', requireAuth, async (req, res) => { } const done: number[] = [] const errors: { file: string; error: string }[] = [] + const fail = (file: string, error: string) => { + errors.push({ file, error }) + logger.error('api', `Échec de l'import manuel de « ${file} » : ${error}`) + } for (const item of items) { const { file_path, episode_id, hash } = item - if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue } + if (!file_path || !episode_id) { fail(file_path ? path.basename(file_path) : '?', 'Paramètres manquants'); continue } const found = episodeIndex.get(Number(episode_id)) - if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue } + if (!found) { fail(path.basename(file_path), `Épisode ${episode_id} introuvable dans la série ${serie_id}`); continue } const { ep, season } = found const srcFilename = path.basename(file_path) const srcExt = path.extname(srcFilename) @@ -188,9 +210,7 @@ router.post('/manual-import', requireAuth, async (req, res) => { apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry }) done.push(episode_id) } catch (err) { - const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' - errors.push({ file: srcFilename, error: msg }) - logger.error('api', `Échec de l'import manuel de « ${srcFilename} » : ${msg}`) + fail(srcFilename, err instanceof Error ? err.message : UNEXPECTED_ERROR) } } if (ops.length > 0) { @@ -497,10 +517,12 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes }) } } - const orphans = complete - ? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) })) - : [] - res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete }) + const candidates = complete ? findOrphans(organized, seriesData) : [] + const apiOnly = candidates.length > 0 ? await apiOnlyEpisodes(candidates, seriesData, readSettings().mediaPath) : new Set() + const orphans = (apiOnly ? candidates : []) + .filter(o => !apiOnly?.has(String(o.episode_id))) + .map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) })) + res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete && !!apiOnly }) } catch (err) { logger.error('api', `Échec du récapitulatif des imports : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR }) @@ -511,12 +533,18 @@ router.get('/organized-summary', requireAuth, async (_req, res) => { router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => { const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null const { series, complete } = await loadCatalogStatus() - if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard' }); return } + const catalogIncomplete = 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard' + if (!complete) { res.status(503).json({ error: catalogIncomplete }); return } + let apiOnly: Set | null + try { apiOnly = await apiOnlyEpisodes(findOrphans(readOrganized(), series), series, readSettings().mediaPath) } + catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return } + if (!apiOnly) { res.status(503).json({ error: catalogIncomplete }); return } let removed = 0 try { updateOrganized(data => { for (const o of findOrphans(data, series)) { if (only && !only.has(String(o.episode_id))) continue + if (apiOnly.has(String(o.episode_id))) continue delete data[o.hash][String(o.episode_id)] removed++ }