271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
|
||
|
||
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 // date ISO d'activation de la surveillance
|
||
}
|
||
|
||
type SyncedMap = Record<number, SyncedSerie>
|
||
|
||
// ── 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 et même nom : la surveillance suit ; renvoie celles sans série
|
||
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
|
||
}
|
||
|
||
// ── Épisodes déjà importés ────────────────────────────────────
|
||
|
||
function readOrganized(): Record<string, Record<string, any>> {
|
||
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<string, Record<string, any>>): Set<number> {
|
||
const ids = new Set<number>()
|
||
|
||
// 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
|
||
}
|
||
|
||
// ── Vérification des séries surveillées ───────────────────────
|
||
|
||
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', `Surveillance : vérification de ${ids.length} série(s)`)
|
||
|
||
const organized = readOrganized()
|
||
|
||
const activeHashes = new Set<string>()
|
||
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} ») absente du catalogue : retirez la surveillance dans 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 de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
|
||
|
||
const candidateIds = new Set<number>()
|
||
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
|
||
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} épisode(s) à télécharger`)
|
||
|
||
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
|
||
const coveredIds = new Set<number>()
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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é au client : ${dl.label}`)
|
||
sent++
|
||
} else {
|
||
logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
|
||
errors++
|
||
}
|
||
} catch (err) {
|
||
logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
|
||
errors++
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
|
||
return { sent, skipped, errors }
|
||
}
|