fix(import): repli sur l'API Fankai quand une série est absente du scraper et retrait de Kodi des NFO
This commit is contained in:
1 parent
ca971f4daf
commit
67794d92c8
6 files changed
+79
-36
No files matched your search
@@ -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<any> {
|
||||
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)
|
||||
}
|
||||
|
||||
// Repli sur l'API Fankai quand le scraper n'a pas (encore) la série
|
||||
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
|
||||
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?:\/\//, '')
|
||||
|
||||
+19
-18
@@ -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<number, { ep: any; season: any }>()
|
||||
@@ -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<string, any> = {}
|
||||
@@ -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 }
|
||||
|
||||
+8
-15
@@ -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<string, Record<string, any>> = {}
|
||||
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<number>()
|
||||
|
||||
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' })
|
||||
|
||||
@@ -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 }[]
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Série absente du scraper -->
|
||||
<div v-if="auth.isAdmin && !data.scraper_synced" class="mx-4 md:mx-8 mt-4 flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Série absente du scraper</p>
|
||||
<p class="text-xs text-muted">
|
||||
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre utilisateur (mode demande) -->
|
||||
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<div v-if="myRequest" class="flex items-center gap-3">
|
||||
|
||||
@@ -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.)."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
@@ -164,12 +164,12 @@
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex ou Emby</span>,
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
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.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Kodi, Infuse</span>
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user