diff --git a/server/requests.ts b/server/requests.ts index 51583e2..7abb2cd 100644 --- a/server/requests.ts +++ b/server/requests.ts @@ -15,6 +15,7 @@ export interface Requester { userId : string username : string seasons : number[] // numéros de saison ; [] = toutes les saisons dispo + episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé requestedAt: string } @@ -47,12 +48,19 @@ function writeRequests(requests: SerieRequest[]): void { /** Union des saisons demandées sur tous les requesters ([] = toutes) */ export function mergedSeasons(req: SerieRequest): number[] { - if (req.requesters.some(r => r.seasons.length === 0)) return [] + if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return [] const all = new Set() req.requesters.forEach(r => r.seasons.forEach(s => all.add(s))) return [...all].sort((a, b) => a - b) } +/** Union des IDs d'épisodes spécifiques sur tous les requesters */ +export function mergedEpisodes(req: SerieRequest): number[] { + const all = new Set() + req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e))) + return [...all].sort((a, b) => a - b) +} + // ── CRUD ────────────────────────────────────────────────────── /** @@ -61,10 +69,11 @@ export function mergedSeasons(req: SerieRequest): number[] { * - Sinon → crée une nouvelle demande */ export function upsertRequest( - userId : string, - serieId : number, + userId : string, + serieId : number, serieName: string, - seasons : number[], // [] = toutes les saisons + seasons : number[], // [] = toutes les saisons + episodes : number[] = [], // IDs d'épisodes spécifiques ): SerieRequest { const user = findById(userId) if (!user) throw new Error('Utilisateur introuvable') @@ -84,16 +93,23 @@ export function upsertRequest( // Mettre à jour ou ajouter ce requester dans la demande existante const idx = existing.requesters.findIndex(r => r.userId === userId) if (idx >= 0) { - // Mettre à jour les saisons (union) - if (seasons.length === 0 || existing.requesters[idx].seasons.length === 0) { - existing.requesters[idx].seasons = [] // l'une ou l'autre est "toutes" → toutes + const r = existing.requesters[idx] + // Fusionner les saisons + if (seasons.length === 0 && episodes.length === 0) { + r.seasons = [] // "toutes" — efface toute granularité + r.episodes = [] } else { - const merged = new Set([...existing.requesters[idx].seasons, ...seasons]) - existing.requesters[idx].seasons = [...merged].sort((a, b) => a - b) + if (seasons.length === 0 || r.seasons.length === 0) { + r.seasons = [] // l'une des deux est "toutes" → toutes + } else { + r.seasons = [...new Set([...r.seasons, ...seasons])].sort((a, b) => a - b) + } + // Fusionner les épisodes (union simple) + r.episodes = [...new Set([...(r.episodes ?? []), ...episodes])].sort((a, b) => a - b) } - existing.requesters[idx].requestedAt = now + r.requestedAt = now } else { - existing.requesters.push({ userId, username: user.username, seasons, requestedAt: now }) + existing.requesters.push({ userId, username: user.username, seasons, episodes, requestedAt: now }) } existing.updatedAt = now writeRequests(requests) @@ -106,7 +122,7 @@ export function upsertRequest( id : crypto.randomUUID(), serieId, serieName, - requesters: [{ userId, username: user.username, seasons, requestedAt: now }], + requesters: [{ userId, username: user.username, seasons, episodes, requestedAt: now }], status : 'pending', createdAt : now, updatedAt : now, diff --git a/server/routes/requests.ts b/server/routes/requests.ts index 6bbab66..2a295d0 100644 --- a/server/routes/requests.ts +++ b/server/routes/requests.ts @@ -3,8 +3,12 @@ import { requireAdmin } from '../auth.js' import { readRequests, upsertRequest, approveRequest, rejectRequest, completeRequest, deleteRequest, - getRequestsForUser, getPendingCount, mergedSeasons, + getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes, + type SerieRequest, } from '../requests.js' +import { dispatchDownload } from '../torrent-clients/index.js' +import { readSerieData } from '../lib/github-cache.js' +import { logger } from '../logger.js' const router = Router() @@ -26,7 +30,7 @@ router.get('/requests/pending-count', (req, res) => { // POST /api/requests — créer ou mettre à jour une demande router.post('/requests', (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 } @@ -35,7 +39,8 @@ router.post('/requests', (req, res) => { req.user!.id, Number(serieId), String(serieName), - Array.isArray(seasons) ? seasons.map(Number) : [], + Array.isArray(seasons) ? seasons.map(Number) : [], + Array.isArray(episodes) ? episodes.map(Number) : [], ) res.json(request) } catch (err) { @@ -44,14 +49,19 @@ router.post('/requests', (req, res) => { }) // PATCH /api/requests/:id — admin : approve / reject / complete -router.patch('/requests/:id', requireAdmin, (req, res) => { +router.patch('/requests/:id', requireAdmin, async (req, res) => { const { action, rejectionMessage } = req.body try { - let request + let request: SerieRequest const id = String(req.params.id) - if (action === 'approve') request = approveRequest(id) - else if (action === 'reject') request = rejectRequest(id, rejectionMessage) - else if (action === 'complete') request = completeRequest(id) + if (action === 'approve') { + request = approveRequest(id) + // Lancer le(s) téléchargement(s) correspondant à la demande + 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 } res.json(request) } catch (err) { @@ -59,6 +69,82 @@ router.patch('/requests/:id', requireAdmin, (req, res) => { } }) +/** Trouve et envoie au client torrent les fichiers correspondant à la demande. */ +async function autoDownloadRequest(req: SerieRequest): Promise { + const serieData = await readSerieData(req.serieId) + if (!serieData) { + logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`) + return + } + + const seasons = mergedSeasons(req) // [] = toutes + const episodes = mergedEpisodes(req) // IDs d'épisodes spécifiques + + type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null } + const toDownload: TorrentEntry[] = [] + const seenHashes = new Set() + + function addTorrent(t: any, file_index?: number | null, file_path?: string | null) { + const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}` + if (seenHashes.has(key)) return + seenHashes.add(key) + toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null }) + } + + if (episodes.length > 0) { + // ── Épisodes ciblés ─────────────────────────────────────── + for (const season of serieData.seasons ?? []) { + for (const ep of season.episodes ?? []) { + if (!episodes.includes(ep.id)) continue + if (ep.torrents?.length > 0) { + // Torrent individuel + for (const t of ep.torrents) addTorrent(t) + } else if (ep.paths?.length > 0) { + // Épisode contenu dans un pack — télécharger le fichier spécifique + for (const p of ep.paths) { + const pack = [...(serieData.torrents ?? []), ...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? [])] + .find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase()) + if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null) + } + } + } + } + } else if (seasons.length > 0) { + // ── Saisons ciblées ─────────────────────────────────────── + for (const season of serieData.seasons ?? []) { + if (!seasons.includes(season.season_number)) continue + for (const t of season.torrents ?? []) addTorrent(t) + } + } else { + // ── Toutes les saisons : intégrale en priorité ──────────── + for (const t of serieData.torrents ?? []) addTorrent(t) + // Sinon packs saison + if (toDownload.length === 0) { + for (const season of serieData.seasons ?? []) { + for (const t of season.torrents ?? []) addTorrent(t) + } + } + } + + if (toDownload.length === 0) { + logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`) + return + } + + let sent = 0 + for (const t of toDownload) { + const url = t.url ?? t.magnet + if (!url) continue + try { + await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null }) + sent++ + } catch (err) { + logger.warn('requests', `Auto-dl erreur torrent : ${err instanceof Error ? err.message : err}`) + } + } + logger.info('requests', `Auto-dl "${req.serieName}" — ${sent}/${toDownload.length} torrent(s) envoyé(s)`) +} + // DELETE /api/requests/:id — admin seulement router.delete('/requests/:id', requireAdmin, (req, res) => { try { diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index bbd05ba..98ddfe4 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -206,12 +206,13 @@