/** * RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance. * * Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added` * (date d'ajout dans la base metadata) est postérieur à la date d'activation * du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont * pas encore organisés, ils sont envoyés automatiquement au client torrent. */ import path from 'path' import fs from 'fs' import { DATA_DIR } from '../config.js' import { logger } from '../logger.js' import { readInfohashMap } from './github-cache.js' import { loadCatalogStatus } from './serie-helpers.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 — date d'activation de la surveillance } 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 } // Une série déjà surveillée garde sa date d'activation export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } { const map = loadSynced() let changed = 0 for (const { id, name } of series) { if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ } if (!enabled && map[id]) { delete map[id]; changed++ } } if (changed > 0) saveSynced(map) return { changed, map } } export function isSynced(serieId: number): boolean { return !!loadSynced()[serieId] } const normalizeName = (name: string) => name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() // Série recréée sous un nouvel ID avec le même nom : la surveillance suit. // Renvoie les surveillances dont la série n'existe plus dans le catalogue. export function reconcileSynced(catalog: any[]): SyncedSerie[] { const map = loadSynced() const ids = new Set(catalog.map(sd => sd.id)) const orphans: SyncedSerie[] = [] let changed = false for (const entry of Object.values(map)) { if (ids.has(entry.serieId)) continue const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName)) if (matches.length === 1 && !map[matches[0].id]) { delete map[entry.serieId] map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title } logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`) changed = true } else { orphans.push(entry) } } if (changed) saveSynced(map) return orphans } // ── Helpers ─────────────────────────────────────────────────── 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 {} } function getOrganizedEpisodeIds(sd: any, organized: Record>): Set { const ids = new Set() // Sous n'importe quel hash : évite de retélécharger un épisode importé via un torrent disparu du scraper const tracked = new Set(Object.values(organized).flatMap(eps => Object.keys(eps))) for (const season of sd.seasons ?? []) for (const ep of season.episodes ?? []) if (tracked.has(String(ep.id))) ids.add(ep.id) 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) } 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) 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 } // ── Logique de sync ─────────────────────────────────────────── export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> { if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 } const { series: seriesData, complete } = await loadCatalogStatus() if (complete) reconcileSynced(seriesData) const synced = loadSynced() const ids = Object.keys(synced).map(Number) logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`) const organized = readOrganized() 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 syncedEntry = synced[serieId] const sd = seriesData.find((s: any) => s.id === serieId) if (!sd) { logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`) continue } const title = sd.title ?? sd.show_title ?? String(serieId) const activatedAt = new Date(syncedEntry.addedAt) const organizedIds = getOrganizedEpisodeIds(sd, organized) logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`) // Épisodes candidats : date_added > date d'activation ET pas encore organisés const candidateIds = new Set() for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { if (!ep.date_added) continue const epDate = new Date(ep.date_added) if (epDate <= activatedAt) continue if (organizedIds.has(ep.id)) continue // Doit avoir au moins un torrent fankai const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet)) if (!hasTorrent) continue candidateIds.add(ep.id) } } if (candidateIds.size === 0) { logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`) continue } logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`) // Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = [] const coveredIds = new Set() // 1. Packs saison couvrant au moins un candidat 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) } } // 2. Épisodes individuels candidats non couverts par un pack 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 } } } 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 } }