diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts index 349133c..f4c460b 100644 --- a/server/lib/serie-helpers.ts +++ b/server/lib/serie-helpers.ts @@ -1,4 +1,6 @@ import path from 'path' +import { logger } from '../logger.js' +import { githubGet } from './github-cache.js' const FANKAI_API = 'https://metadata.fankai.fr' @@ -8,6 +10,41 @@ export async function fankaiGet(endpoint: string): Promise { 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) +} + +// Repli sur l'API Fankai quand le scraper n'a pas (encore) la série +export async function resolveSerieData(serieId: number, action?: string): Promise { + let scraperError = 'aucun épisode' + try { + const sd = await githubGet(`series/${serieId}.json`) + if (serieHasEpisodes(sd)) return sd + } catch (err) { + scraperError = err instanceof Error ? err.message : String(err) + } + 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 (${err instanceof Error ? err.message : err})`) + return null + } +} + export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { if (!url) return null const stripped = url.replace(/^https?:\/\//, '') diff --git a/server/routes/import.ts b/server/routes/import.ts index f575fb0..2e0aa69 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -5,8 +5,8 @@ import { requireAuth, requireAdmin } from '../auth.js' import { logger } from '../logger.js' import { readSettings } from '../settings.js' import { getGitlabTitle } from '../gitlab-map.js' -import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js' -import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js' +import { loadEnrichedSeriesData } from '../lib/github-cache.js' +import { resolveEpNaming, computeExpectedName, resolveSerieData } from '../lib/serie-helpers.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { dispatchRemove } from '../torrent-clients/index.js' import { readRequests, deleteRequest } from '../requests.js' @@ -14,6 +14,8 @@ import { readOrganized, writeOrganized, updateOrganized, type Organized } from ' const router = Router() +const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)` + // ── Import manuel ────────────────────────────────────────────── router.post('/manual-import', requireAuth, async (req, res) => { const { serie_id, items } = req.body @@ -27,14 +29,8 @@ router.post('/manual-import', requireAuth, async (req, res) => { // Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin const ops: ((data: Organized) => void)[] = [] const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) } - let sd = await readSerieData(Number(serie_id)) - if (!sd) { - // readSerieData ne couvre que le cache local — fallback sur le catalogue complet - // (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel) - const all = await loadEnrichedSeriesData() - sd = all.find((s: any) => s.id === Number(serie_id)) ?? null - } - if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return } + const sd = await resolveSerieData(Number(serie_id), 'Import manuel') + if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return } const rawTitle = sd.title ?? sd.show_title ?? '' const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() const episodeIndex = new Map() @@ -177,8 +173,8 @@ router.post('/manual-import', requireAuth, async (req, res) => { router.get('/organized/:serieId', requireAuth, async (req, res) => { const serieId = Number(req.params.serieId) try { - const sd = await readSerieData(serieId) - if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + const sd = await resolveSerieData(serieId) + if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return } const organized = readOrganized() const { nfoSupport } = readSettings() const result: Record = {} @@ -212,8 +208,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => { const { serie_id, episode_id, torrent_hash } = req.body if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return } const { nfoSupport } = readSettings() - const sd = await readSerieData(Number(serie_id)) - if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + const sd = await resolveSerieData(Number(serie_id), 'Renommage') + if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return } let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } @@ -258,8 +254,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => { const serieId = String(req.params.serieId) const deleteFile = req.query.deleteFile === 'true' - const sd = await readSerieData(Number(serieId)) - if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + const sd = await resolveSerieData(Number(serieId), 'Désimport série') + if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return } let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } @@ -320,8 +316,8 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, const serieId = String(req.params.serieId) const seasonId = Number(req.params.seasonId) const deleteFile = req.query.deleteFile === 'true' - const sd = await readSerieData(Number(serieId)) - if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + const sd = await resolveSerieData(Number(serieId), 'Désimport saison') + if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return } let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } @@ -449,6 +445,11 @@ router.post('/rename-all', requireAdmin, async (req, res) => { const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null const { nfoSupport } = readSettings() const seriesData = await loadEnrichedSeriesData() + if (onlyIds) { + const known = new Set(seriesData.map((sd: any) => sd.id)) + const missing = await Promise.all([...onlyIds].filter(id => !known.has(id)).map(id => resolveSerieData(id, 'Rename masse'))) + seriesData.push(...missing.filter(Boolean)) + } let organized: Organized try { organized = readOrganized() } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return } diff --git a/server/routes/series.ts b/server/routes/series.ts index 13e28c3..b463a4b 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -9,9 +9,9 @@ import { dispatchList } from '../torrent-clients/index.js' import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js' import { loadSynced } from '../lib/rss-sync.js' import { - fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, + fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, - deduplicateEpisodes, + deduplicateEpisodes, serieHasEpisodes, } from '../lib/serie-helpers.js' const router = Router() @@ -46,14 +46,9 @@ router.get('/series', requireAuth, async (_req, res) => { router.get('/series/:id', requireAuth, async (req, res) => { const id = Number(req.params.id) try { - const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)]) + const [{ seasons, ...serieRaw }, serieData] = await Promise.all([fetchSerieFromApi(id), readSerieData(id)]) const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null } - const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) - const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => { - const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) - const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) - return { ...normalizeSeason(season), episodes } - })) + const seasonsWithEpisodes = seasons.map((season: any) => ({ ...normalizeSeason(season), episodes: season.episodes.map(normalizeEpisode) })) let organized: Record> = {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} @@ -64,11 +59,9 @@ router.get('/series/:id', requireAuth, async (req, res) => { const organizedEpisodeIds = new Set() const manualOrg = organized['manual'] ?? {} - if (serieData) { - for (const season of serieData.seasons ?? []) { - for (const ep of season.episodes ?? []) { - if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id) - } + for (const season of seasonsWithEpisodes) { + for (const ep of season.episodes) { + if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id) } } if (serieData) { @@ -191,7 +184,7 @@ router.get('/series/:id', requireAuth, async (req, res) => { } }) - res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) }) + res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) }) } catch (err) { logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) diff --git a/src/stores/series.ts b/src/stores/series.ts index 39b3f14..adb29c3 100644 --- a/src/stores/series.ts +++ b/src/stores/series.ts @@ -55,6 +55,7 @@ export interface Serie { export interface SerieDetail { serie: Serie seasons: Season[] + scraper_synced: boolean torrents_integrale: { raw: string; torrent_url: string | null; magnet: string | null }[] } diff --git a/src/views/SerieView.vue b/src/views/SerieView.vue index 8b8650a..e8c571d 100644 --- a/src/views/SerieView.vue +++ b/src/views/SerieView.vue @@ -103,6 +103,17 @@ + +
+ ⚠ +
+

Série absente du scraper

+

+ Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation. +

+
+
+
diff --git a/src/views/settings/MediaManagementView.vue b/src/views/settings/MediaManagementView.vue index 54c5ee2..e8d1bcd 100644 --- a/src/views/settings/MediaManagementView.vue +++ b/src/views/settings/MediaManagementView.vue @@ -90,7 +90,7 @@ :model-value="form.nfoSupport" @update:model-value="onNfoToggle" label="NFO / Métadonnées" - description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)." + description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)." />

Activer les NFO / Métadonnées ?

- Si vous utilisez l'agent Fankai sur Jellyfin, Plex ou Emby, + Si vous utilisez l'agent Fankai sur Jellyfin, Plex, Emby ou Kodi, les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile et peut créer des conflits.

- Activez cette option uniquement si vous utilisez Kodi, Infuse + Activez cette option uniquement si vous utilisez Infuse ou un lecteur qui lit les fichiers NFO locaux.