From 086ca3053f5e563d8c3c2bb82379ff45d77a5499 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Sun, 10 May 2026 22:53:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20fix=20de=20plusieurs=20torrent?= =?UTF-8?q?=20et=20refonte=20du=20tableau=20de=20compatibilit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(rss): ajout du rss sur les series --- server/index.ts | 11 + server/lib/rss-sync.ts | 302 ++++++++++++++++++++++ server/routes/rss-sync.ts | 48 ++++ server/torrent-clients/rtorrent.ts | 4 +- server/torrent-clients/synology-ds.ts | 2 +- src/views/SerieView.vue | 45 +++- src/views/settings/DownloadClientView.vue | 89 +++++-- 7 files changed, 474 insertions(+), 27 deletions(-) create mode 100644 server/lib/rss-sync.ts create mode 100644 server/routes/rss-sync.ts diff --git a/server/index.ts b/server/index.ts index 90494c7..8050572 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,8 @@ import importRouter from './routes/import.js' import systemRouter from './routes/system.js' import nfoUpdatesRouter from './routes/nfo-updates.js' import plexRouter from './routes/plex.js' +import rssSyncRouter from './routes/rss-sync.js' +import { runRssSync } from './lib/rss-sync.js' registerDriver(qbittorrentDriver) registerDriver(transmissionDriver) @@ -63,6 +65,7 @@ app.use('/api', importRouter) app.use('/api', systemRouter) app.use('/api', nfoUpdatesRouter) app.use('/api', plexRouter) +app.use('/api', rssSyncRouter) // ── Catch-all SPA ────────────────────────────────────────────── if (fs.existsSync(PUBLIC_PATH)) { @@ -161,4 +164,12 @@ server.listen(PORT, async () => { logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`) } }, 60 * 60_000) + + // RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures. + setTimeout(() => { + runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`)) + setInterval(() => { + 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 }) diff --git a/server/lib/rss-sync.ts b/server/lib/rss-sync.ts new file mode 100644 index 0000000..58942fa --- /dev/null +++ b/server/lib/rss-sync.ts @@ -0,0 +1,302 @@ +/** + * RSS Sync — auto-téléchargement des nouveaux épisodes pour les séries surveillées. + * + * Toutes les 6 heures, FanKarr vérifie si de nouveaux épisodes sont apparus + * APRÈS le dernier épisode organisé de chaque saison. + * + * Règle de frontière : si le dernier épisode organisé d'une saison est le 9, + * seuls les épisodes 10+ sont candidats au téléchargement automatique. + * Si aucun épisode n'est encore organisé dans une saison, rien n'est déclenché + * pour cette saison (l'utilisateur doit lancer le premier téléchargement manuellement). + */ + +import path from 'path' +import fs from 'fs' +import { DATA_DIR } from '../config.js' +import { logger } from '../logger.js' +import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js' +import { dispatchDownload, dispatchList } from '../torrent-clients/index.js' +import { readSettings } from '../settings.js' + +// ── Types ───────────────────────────────────────────────────── + +export interface SyncedSerie { + serieId : number + serieName: string + addedAt : string // ISO +} + +type SyncedMap = Record + +// ── Persistance ─────────────────────────────────────────────── + +const SYNC_FILE = path.join(DATA_DIR, 'rss-synced.json') + +export function loadSynced(): SyncedMap { + try { + if (fs.existsSync(SYNC_FILE)) + return JSON.parse(fs.readFileSync(SYNC_FILE, 'utf-8')) as SyncedMap + } catch {} + return {} +} + +function saveSynced(map: SyncedMap): void { + fs.mkdirSync(DATA_DIR, { recursive: true }) + fs.writeFileSync(SYNC_FILE, JSON.stringify(map, null, 2), 'utf-8') +} + +export function setSync(serieId: number, serieName: string, enabled: boolean): SyncedMap { + const map = loadSynced() + if (enabled) { + map[serieId] = { serieId, serieName, addedAt: new Date().toISOString() } + } else { + delete map[serieId] + } + saveSynced(map) + return map +} + +export function isSynced(serieId: number): boolean { + return !!loadSynced()[serieId] +} + +// ── Helpers organisés ───────────────────────────────────────── + +function readOrganized(): Record> { + try { + const p = path.join(DATA_DIR, 'organized.json') + if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8')) + } catch {} + return {} +} + +/** + * Retourne l'ensemble des IDs d'épisodes organisés pour une série donnée, + * en croisant organized.json avec les données de la série. + */ +function getOrganizedEpisodeIds(sd: any, organized: Record>): Set { + const ids = new Set() + + // Import manuel + const manual = organized['manual'] ?? {} + for (const season of sd.seasons ?? []) + for (const ep of season.episodes ?? []) + if (manual[String(ep.id)]) ids.add(ep.id) + + // Intégrales + for (const t of sd.torrents ?? []) { + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + for (const season of sd.seasons ?? []) + for (const ep of season.episodes ?? []) + if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id) + } + + // Packs saison + épisodes individuels + for (const season of sd.seasons ?? []) { + for (const t of season.torrents ?? []) { + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + for (const ep of season.episodes ?? []) + if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id) + } + for (const ep of season.episodes ?? []) { + for (const t of ep.torrents ?? []) { + const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} + if (orgFiles[String(ep.id)] !== undefined) ids.add(ep.id) + // Fallback filename + const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) + if (pathEntry) { + const fname = pathEntry.path?.split('/').pop() + if (fname && orgFiles[fname] !== undefined) ids.add(ep.id) + } + } + } + } + + return ids +} + +/** + * Pour une saison donnée, retourne le numéro d'épisode maximal parmi + * les épisodes organisés, ou 0 si aucun n'est organisé. + */ +function maxOrganizedEpNumber(season: any, organizedIds: Set): number { + let max = 0 + for (const ep of season.episodes ?? []) { + if (organizedIds.has(ep.id) && ep.episode_number > max) + max = ep.episode_number + } + return max +} + +// ── Logique de sync ─────────────────────────────────────────── + +/** + * Lance un cycle de RSS sync. + * + * Par série surveillée : + * - Pour chaque saison, calcule la frontière = dernier épisode organisé. + * - Ne propose au téléchargement que les épisodes APRÈS cette frontière. + * - Si aucun épisode n'est organisé dans une saison → saison ignorée + * (l'utilisateur doit lancer le premier téléchargement manuellement). + * - Ne renvoie pas un torrent déjà actif dans le client. + */ +export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> { + const synced = loadSynced() + const ids = Object.keys(synced).map(Number) + if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 } + + logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`) + + const organized = readOrganized() + const seriesData = await loadEnrichedSeriesData() + + // Torrents actifs dans le client + const activeHashes = new Set() + try { + const { category } = readSettings() + const infohashMap = await readInfohashMap() + const active = await dispatchList(category ?? 'fankai', infohashMap) + for (const t of active) if (t.hash) activeHashes.add(t.hash.toLowerCase()) + } catch (err) { + logger.warn('rss-sync', `Impossible de lire les torrents actifs : ${err instanceof Error ? err.message : err}`) + } + + let sent = 0 + let skipped = 0 + let errors = 0 + + for (const serieId of ids) { + const sd = seriesData.find((s: any) => s.id === serieId) + if (!sd) { + logger.warn('rss-sync', `Série ${serieId} introuvable dans le cache`) + continue + } + + const title = sd.title ?? sd.show_title ?? String(serieId) + const organizedIds = getOrganizedEpisodeIds(sd, organized) + + if (organizedIds.size === 0) { + logger.info('rss-sync', `${title} — aucun épisode organisé, sync ignoré (lance le 1er téléchargement manuellement)`) + skipped++ + continue + } + + logger.info('rss-sync', `Vérification : ${title} (${organizedIds.size} épisodes organisés)`) + + // Épisodes candidats = après la frontière, par saison + // Un épisode est candidat si : episode_number > max organisé dans cette saison + const candidateIds = new Set() + for (const season of sd.seasons ?? []) { + const frontier = maxOrganizedEpNumber(season, organizedIds) + if (frontier === 0) continue // aucun épisode organisé dans cette saison → skip + + for (const ep of season.episodes ?? []) { + if (ep.episode_number > frontier && !organizedIds.has(ep.id)) + candidateIds.add(ep.id) + } + + if (candidateIds.size > 0) + logger.info('rss-sync', `${title} S${season.season_number} — frontière ep${frontier}, ${ + [...candidateIds].length} candidat(s)`) + } + + if (candidateIds.size === 0) { + logger.info('rss-sync', `${title} — aucun nouvel épisode`) + continue + } + + // Construire la liste des téléchargements (intégrale → pack saison → épisode) + const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = [] + const coveredIds = new Set() + + // 1. Intégrale(s) — uniquement si elle couvre au moins un candidat + for (const t of sd.torrents ?? []) { + if (!t.fankai) continue + const url = t.torrent_url ?? t.magnet + if (!url) continue + const hash = t.infohash?.toLowerCase() + if (hash && activeHashes.has(hash)) { skipped++; continue } + + const integraleEps: any[] = [] + for (const season of sd.seasons ?? []) + for (const ep of season.episodes ?? []) + if (ep.torrents?.some((et: any) => et.infohash?.toLowerCase() === hash)) + integraleEps.push(ep) + + const coversSomeCandidate = integraleEps.some(ep => candidateIds.has(ep.id)) + if (!coversSomeCandidate) { skipped++; continue } + + toDownload.push({ url, infohash: hash ?? null, label: `intégrale ${title}` }) + for (const ep of integraleEps) coveredIds.add(ep.id) + } + + // 2. Packs saison — uniquement si le pack couvre au moins un candidat non couvert + for (const season of sd.seasons ?? []) { + for (const t of season.torrents ?? []) { + if (!t.fankai) continue + const url = t.torrent_url ?? t.magnet + if (!url) continue + const hash = t.infohash?.toLowerCase() + if (hash && activeHashes.has(hash)) { skipped++; continue } + + const coversSomeCandidate = (season.episodes ?? []).some((ep: any) => + candidateIds.has(ep.id) && !coveredIds.has(ep.id) + ) + if (!coversSomeCandidate) { skipped++; continue } + + toDownload.push({ url, infohash: hash ?? null, label: `S${season.season_number} ${title}` }) + for (const ep of season.episodes ?? []) coveredIds.add(ep.id) + } + } + + // 3. Épisodes individuels candidats non encore couverts + for (const season of sd.seasons ?? []) { + for (const ep of season.episodes ?? []) { + if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue + + for (const t of ep.torrents ?? []) { + if (!t.fankai) continue + const url = t.torrent_url ?? t.magnet + if (!url) continue + const hash = t.infohash?.toLowerCase() + if (hash && activeHashes.has(hash)) { skipped++; continue } + + const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === hash) + toDownload.push({ + url, + infohash : hash ?? null, + file_index: pathEntry?.file_index ?? null, + file_path : pathEntry?.path ?? null, + label : `S${season.season_number}E${ep.episode_number} ${title}`, + }) + coveredIds.add(ep.id) + break + } + } + } + + // Envoyer + for (const dl of toDownload) { + try { + const results = await dispatchDownload(dl.url, { + file_index: dl.file_index, + file_path : dl.file_path, + infohash : dl.infohash, + }) + if (results.some((r: any) => r.ok)) { + logger.info('rss-sync', `Envoyé : ${dl.label}`) + sent++ + } else { + logger.warn('rss-sync', `Échec envoi : ${dl.label}`) + errors++ + } + } catch (err) { + logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`) + errors++ + } + } + } + + logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`) + return { sent, skipped, errors } +} diff --git a/server/routes/rss-sync.ts b/server/routes/rss-sync.ts new file mode 100644 index 0000000..fe559d4 --- /dev/null +++ b/server/routes/rss-sync.ts @@ -0,0 +1,48 @@ +import { Router } from 'express' +import { requireAuth } from '../auth.js' +import { logger } from '../logger.js' +import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js' + +const router = Router() + +/** GET /api/rss-sync → liste des séries surveillées */ +router.get('/rss-sync', requireAuth, (_req, res) => { + res.json(Object.values(loadSynced())) +}) + +/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (avant /:id) */ +router.post('/rss-sync/run', requireAuth, async (_req, res) => { + try { + const result = await runRssSync() + res.json({ ok: true, ...result }) + } catch (err) { + logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`) + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) + } +}) + +/** GET /api/rss-sync/:id → statut sync pour une série */ +router.get('/rss-sync/:id', requireAuth, (req, res) => { + const serieId = Number(req.params.id) + res.json({ synced: isSynced(serieId) }) +}) + +/** POST /api/rss-sync/:id → activer la surveillance */ +router.post('/rss-sync/:id', requireAuth, (req, res) => { + const serieId = Number(req.params.id) + const serieName = String(req.body.serieName ?? '') + if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return } + const map = setSync(serieId, serieName, true) + logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`) + res.json({ synced: true, total: Object.keys(map).length }) +}) + +/** DELETE /api/rss-sync/:id → désactiver la surveillance */ +router.delete('/rss-sync/:id', requireAuth, (req, res) => { + const serieId = Number(req.params.id) + const map = setSync(serieId, '', false) + logger.info('rss-sync', `Surveillance désactivée pour la série ${serieId}`) + res.json({ synced: false, total: Object.keys(map).length }) +}) + +export default router diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index 64d2732..d3cc694 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -244,7 +244,7 @@ const RT: TorrentClientDriver = { await Promise.all(rows.map(async (r: any) => { const hash = String(r[0]).toUpperCase() try { - const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=']) + const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path=', 'f.priority=']) if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files) } catch {} })) @@ -279,7 +279,7 @@ const RT: TorrentClientDriver = { index : i, name : String(f[2] ?? ''), progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0, - priority: 1, + priority: Number(f[3] ?? 1) === 0 ? 0 : 1, })) : undefined, } satisfies TorrentInfo diff --git a/server/torrent-clients/synology-ds.ts b/server/torrent-clients/synology-ds.ts index b4db9bb..46065d5 100644 --- a/server/torrent-clients/synology-ds.ts +++ b/server/torrent-clients/synology-ds.ts @@ -166,7 +166,7 @@ const DS: TorrentClientDriver = { version: '1', method : 'delete', id : found.id, - force_complete: deleteFiles ? 'false' : 'false', + force_complete: deleteFiles ? 'true' : 'false', _sid : sid, }) const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) diff --git a/src/views/SerieView.vue b/src/views/SerieView.vue index 1e94dc9..c10a8ad 100644 --- a/src/views/SerieView.vue +++ b/src/views/SerieView.vue @@ -50,6 +50,24 @@
+ + +
-

- ✓ Natif — sélection à l'ajout, aucun fichier superflu · - Async ✓ — sélection dès réception des métadonnées, avant tout téléchargement · - ✗ — non supporté, torrent complet téléchargé -

+
+ Async ✓ — sélection dès réception des métadonnées, aucun octet superflu téléchargé + ✓ Proxy — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés) + URL — le client télécharge le .torrent directement depuis l'URL +
@@ -207,11 +231,30 @@ const availableClients = ref([]) const healthStatus = ref>({}) const clientMatrix = [ - { id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true }, - { id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true }, - { id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true }, - { id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true }, - { id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false }, + { + id: 'qbittorrent', label: 'qBittorrent', version: '4.1+', + auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true, + }, + { + id: 'transmission', label: 'Transmission', version: '3.x / 4.x', + auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true, + }, + { + id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+', + auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true, + }, + { + id: 'utorrent', label: 'uTorrent', version: '3.x', + auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true, + }, + { + id: 'synology-ds', label: 'Synology Download Station', version: '6.x+', + auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true, + }, + { + id: 'real-debrid', label: 'Real-Debrid', version: 'API v1', + auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true, + }, ] const modal = ref({