diff --git a/server/index.ts b/server/index.ts index e116cdb..c7deb81 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/gi import { pushNotif } from './lib/notifs.js' import { readRequests, completeRequest } from './requests.js' import { checkNfoUpdates } from './lib/nfo.js' +import { runJellyfinSync } from './routes/jellyfin.js' import cors from 'cors'; import usersRouter from './routes/users.js' @@ -237,4 +238,12 @@ server.listen(PORT, async () => { runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`)) }, 6 * 60 * 60_000) }, 60_000) // premier cycle 1 min après le démarrage + + // Jellyfin sync — toutes les heures (no-op si Jellyfin non configuré) + setTimeout(() => { + runJellyfinSync().catch(err => logger.error('jellyfin', `Sync initiale échouée : ${err instanceof Error ? err.message : err}`)) + setInterval(() => { + runJellyfinSync().catch(err => logger.error('jellyfin', `Sync périodique échouée : ${err instanceof Error ? err.message : err}`)) + }, 60 * 60_000) + }, 2 * 60_000) // premier cycle 2 min après le démarrage }) diff --git a/server/routes/jellyfin.ts b/server/routes/jellyfin.ts index 290fc53..6a8602d 100644 --- a/server/routes/jellyfin.ts +++ b/server/routes/jellyfin.ts @@ -36,34 +36,38 @@ router.post('/jellyfin/test', async (_req, res) => { res.json(result) }) +export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> { + const { jellyfinUrl, jellyfinAdminToken } = readSettings() + if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] } + + const jellyfinUsers = await fetchJellyfinUsers() + const fankarrUsers = readUsers() + const results = { created: 0, skipped: 0, users: [] as string[] } + + for (const jUser of jellyfinUsers) { + if (jUser.Policy?.IsDisabled) { results.skipped++; continue } + + const exists = fankarrUsers.some(u => + u.username.toLowerCase() === jUser.Name.toLowerCase() + ) + if (exists) { results.skipped++; continue } + + const randomPass = Math.random().toString(36).slice(2, 10) + + Math.random().toString(36).slice(2, 10) + createUser(jUser.Name, randomPass, 'user') + results.created++ + results.users.push(jUser.Name) + logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`) + } + + logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`) + return results +} + // POST /api/jellyfin/sync router.post('/jellyfin/sync', async (_req, res) => { try { - const jellyfinUsers = await fetchJellyfinUsers() - const fankarrUsers = readUsers() - - const results = { created: 0, skipped: 0, users: [] as string[] } - - for (const jUser of jellyfinUsers) { - // Ignorer les comptes désactivés - if (jUser.Policy?.IsDisabled) { results.skipped++; continue } - - const exists = fankarrUsers.some(u => - u.username.toLowerCase() === jUser.Name.toLowerCase() - ) - - if (exists) { results.skipped++; continue } - - // Créer le compte avec un mot de passe aléatoire (auth via SSO uniquement) - const randomPass = Math.random().toString(36).slice(2, 10) + - Math.random().toString(36).slice(2, 10) - createUser(jUser.Name, randomPass, 'user') - results.created++ - results.users.push(jUser.Name) - logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`) - } - - logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`) + const results = await runJellyfinSync() res.json(results) } catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' }) diff --git a/server/routes/public-api.ts b/server/routes/public-api.ts index 9867352..270a207 100644 --- a/server/routes/public-api.ts +++ b/server/routes/public-api.ts @@ -7,9 +7,11 @@ import { Router } from 'express' import { findByApiToken } from '../users.js' import { validateJellyfinToken } from '../lib/jellyfin.js' import { readUsers, safeUser } from '../users.js' -import { upsertRequest, getRequestsForUser } from '../requests.js' +import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js' +import { autoDownloadRequest } from './requests.js' +import { readSettings } from '../settings.js' import { readAvailable } from '../lib/github-cache.js' -import { fankaiGet, normalizeSerie } from '../lib/serie-helpers.js' +import { fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode } from '../lib/serie-helpers.js' import { logger } from '../logger.js' import type { Request, Response, NextFunction } from 'express' @@ -96,20 +98,83 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => { } }) +// ── GET /api/v1/series/:id ──────────────────────────────────── +// Détail d'une série : saisons + épisodes (proxy FanKai) +router.get('/v1/series/:id', requireApiToken, async (req, res) => { + const id = Number(req.params.id) + if (isNaN(id)) { res.status(400).json({ error: 'id invalide' }); return } + try { + const [serieRaw, seasonsData] = await Promise.all([ + fankaiGet(`/series/${id}`), + fankaiGet(`/series/${id}/seasons`), + ]) + const serie = normalizeSerie(serieRaw) + 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((ep: any) => ({ + id : ep.id, + episode_number: ep.episode_number, + title : ep.title ?? null, + image : normalizeEpisode(ep).thumb_image ?? null, + })) + return { + ...normalizeSeason(season), + season_number: season.season_number, + episodes, + } + }) + ) + + res.json({ + id : serie.id, + title : serie.title, + original_title: serie.original_title ?? null, + image : serie.poster_image ?? null, + seasons : seasonsWithEpisodes, + }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' }) + } +}) + // ── POST /api/v1/requests ───────────────────────────────────── // Créer ou mettre à jour une demande router.post('/v1/requests', requireApiToken, (req, res) => { - const { serieId, serieName, seasons } = req.body + const { serieId, serieName, seasons, episodes } = req.body if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return } try { - const request = upsertRequest( + const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : [] + const submittedEpisodes = Array.isArray(episodes) ? episodes.map(Number) : [] + + let request = upsertRequest( req.user!.id, Number(serieId), String(serieName), - Array.isArray(seasons) ? seasons.map(Number) : [], + submittedSeasons, + submittedEpisodes, ) + + // Auto-approbation + téléchargement si l'utilisateur est autorisé + const { requestAutoDownloadUsers } = readSettings() + const userId = req.user!.id + const allowed = requestAutoDownloadUsers === 'all' + || (Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.includes(userId)) + + if (allowed) { + const isNew = request.status === 'pending' + if (isNew) request = approveRequest(request.id) + const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes } + autoDownloadRequest(request, dlOverride).catch(err => + logger.warn('public-api', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`) + ) + } + res.json(request) } catch (err) { res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' }) diff --git a/server/routes/requests.ts b/server/routes/requests.ts index 54d2aa5..8d37293 100644 --- a/server/routes/requests.ts +++ b/server/routes/requests.ts @@ -91,17 +91,10 @@ router.patch('/requests/:id', requireAdmin, async (req, res) => { const id = String(req.params.id) if (action === 'approve') { request = approveRequest(id) - // Lancer le(s) téléchargement(s) si au moins un requester est autorisé - const { requestAutoDownloadUsers } = readSettings() - const enabled = requestAutoDownloadUsers === 'all' - ? true - : Array.isArray(requestAutoDownloadUsers) && requestAutoDownloadUsers.length > 0 - && request.requesters.some(r => (requestAutoDownloadUsers as string[]).includes(r.userId)) - if (enabled) { - autoDownloadRequest(request).catch(err => - logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`) - ) - } + // Approbation manuelle par l'admin → toujours lancer le téléchargement + autoDownloadRequest(request).catch(err => + logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`) + ) } else if (action === 'reject') request = rejectRequest(id, rejectionMessage) else if (action === 'complete') request = completeRequest(id) else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return } @@ -116,7 +109,7 @@ router.patch('/requests/:id', requireAdmin, async (req, res) => { * @param override - si fourni, utilise ces saisons/épisodes au lieu du merged de la demande * (utile pour ne télécharger que ce qui vient d'être ajouté) */ -async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise { +export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise { const serieData = await readSerieData(req.serieId) if (!serieData) { logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`)