Compare commits

...
17 Commits
Author SHA1 Message Date
Quentin Robert a32b1c64fe Merge pull request #141 from Masutayunikon/release-please--branches--main
chore(main): release 3.22.0
2026-09-11 15:14:18 +02:00
Quentin Robert ffc925dbdb chore(main): release 3.22.0 2026-09-11 15:13:33 +02:00
Quentin Robert f4f213508d Merge pull request #142 from Nackophilz/main
feat: actions de masse sur les séries et fiabilisation des imports
2026-09-11 15:13:04 +02:00
ElPouki 7f06dd654a feat: actions de masse sur les séries et fiabilisation des imports et correction protection admin 2026-09-11 15:10:40 +02:00
Quentin Robert 7479ddf8eb feat(torrent): ajout d'une option pour ignorer les erreurs de certificat ssl 2026-08-24 23:14:02 +02:00
destrodxbad 0768964041 feat(torrent): ajout d'une option pour ignorer les erreurs de certificat SSL (#138) 2026-07-25 18:19:41 +02:00
github-actions[bot] 9b66c57a6a chore: update version.txt to v3.21.0 2026-06-28 10:26:18 +00:00
Quentin Robert 3eb64b175d Merge pull request #137 from Masutayunikon/release-please--branches--main
chore(main): release 3.21.0
2026-06-28 12:25:54 +02:00
Quentin Robert 25a8adc91d chore(main): release 3.21.0 2026-06-28 12:11:12 +02:00
MasutaYunikon d533751ffb Merge remote-tracking branch 'origin/main' 2026-06-28 12:10:45 +02:00
MasutaYunikon 6f1e0dc844 feat(settings): ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X 2026-06-28 12:10:34 +02:00
github-actions[bot] 894a7ed2b1 chore: update version.txt to v3.20.3 2026-06-26 13:25:13 +00:00
Quentin Robert c87b7a4c7f Merge pull request #136 from Masutayunikon/release-please--branches--main
chore(main): release 3.20.3
2026-06-26 15:24:47 +02:00
Quentin Robert a000e349c9 chore(main): release 3.20.3 2026-06-26 15:24:30 +02:00
MasutaYunikon f23e3c04ec Merge remote-tracking branch 'origin/main' 2026-06-26 15:23:45 +02:00
MasutaYunikon 63146fdc27 fix(serie): ajout du label de saisons avec les episodes 2026-06-26 15:21:55 +02:00
github-actions[bot] 7795598346 chore: update version.txt to v3.20.2 2026-06-26 12:52:07 +00:00
34 changed files with 870 additions and 374 deletions

No files matched your search

+2 -1
View File
@@ -5,4 +5,5 @@ node_modules
config config
.env .env
.env.* .env.*
!.env.example !.env.example
.test-sandbox
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+5
View File
@@ -27,3 +27,8 @@ binaries
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Instance de test locale
.test-sandbox
scripts/seed-test-sandbox.mjs
docker-compose.test.yml
+24
View File
@@ -1,5 +1,29 @@
# Changelog # Changelog
## [3.22.0](https://github.com/Masutayunikon/FanKarr/compare/v3.21.0...v3.22.0) (2026-09-11)
### Features
* actions de masse sur les séries et fiabilisation des imports ([f4f2135](https://github.com/Masutayunikon/FanKarr/commit/f4f213508de8500aa5e32e8782f358a22e47ec36))
* actions de masse sur les séries et fiabilisation des imports et correction protection admin ([7f06dd6](https://github.com/Masutayunikon/FanKarr/commit/7f06dd654a72b4960195202ef5597411a54f8d75))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat ssl ([7479ddf](https://github.com/Masutayunikon/FanKarr/commit/7479ddf8eb63579727cbb0f497ead98c90145da5))
* **torrent:** ajout d'une option pour ignorer les erreurs de certificat SSL ([#138](https://github.com/Masutayunikon/FanKarr/issues/138)) ([0768964](https://github.com/Masutayunikon/FanKarr/commit/07689640413cf9ea57ea69e1b50273c80dffd640))
## [3.21.0](https://github.com/Masutayunikon/FanKarr/compare/v3.20.3...v3.21.0) (2026-06-28)
### Features
* **settings:** ajout de englishDirectory qui permet d'avoir Season XX au lieu de Saison X ([6f1e0dc](https://github.com/Masutayunikon/FanKarr/commit/6f1e0dc844e8f97b0966b223d558c09ab498ddc9))
## [3.20.3](https://github.com/Masutayunikon/FanKarr/compare/v3.20.2...v3.20.3) (2026-06-26)
### Bug Fixes
* **serie:** ajout du label de saisons avec les episodes ([63146fd](https://github.com/Masutayunikon/FanKarr/commit/63146fdc27d98acb70cf86c7890ea1236388d717))
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26) ## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fankarr</title> <title>Fankarr</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+30 -25
View File
@@ -14,10 +14,11 @@ import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js' import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js' import realDebridDriver from './torrent-clients/real-debrid.js'
import delugeDriver from './torrent-clients/deluge.js' import delugeDriver from './torrent-clients/deluge.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js' import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js' import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js' import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
import { pushNotif } from './lib/notifs.js' import { pushNotif } from './lib/notifs.js'
import { readRequests, completeRequest } from './requests.js' import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js' import { checkNfoUpdates } from './lib/nfo.js'
@@ -91,6 +92,9 @@ const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients', '/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import', '/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync', '/system', '/nfo-updates', '/plex', '/rss-sync',
'/torrent', '/manual-import', '/organized', '/organized-summary',
'/rename-episode', '/rename-all', '/purge-nfo',
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
] ]
app.use('/api', (req, res, next) => { app.use('/api', (req, res, next) => {
if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) { if (ADMIN_PREFIXES.some(p => req.path === p || req.path.startsWith(p + '/'))) {
@@ -135,40 +139,37 @@ server.listen(PORT, async () => {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
} }
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan // Migration organized.json : si ancien format (valeurs string) → reset + rescan
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) const isOldFormat = Object.values(raw).some((entries: any) =>
const isOldFormat = Object.values(raw).some((entries: any) => Object.values(entries).some(v => typeof v === 'string')
Object.values(entries).some(v => typeof v === 'string') )
) if (isOldFormat) {
if (isOldFormat) { logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation') writeOrganized({})
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
}
} }
} catch {} } catch (err) {
logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`)
}
// Migration organized.json : backfill dest_dir manquant sur les anciennes entrées // Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try { try {
if (fs.existsSync(ORGANIZED_PATH)) { const raw = readOrganized()
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) let patched = 0
let patched = 0 for (const episodes of Object.values(raw)) {
for (const episodes of Object.values(raw)) { for (const entry of Object.values(episodes)) {
for (const entry of Object.values(episodes)) { if (!entry.dest_dir && entry.dest_path) {
if (!entry.dest_dir && entry.dest_path) { entry.dest_dir = path.dirname(entry.dest_path)
entry.dest_dir = path.dirname(entry.dest_path) patched++
patched++
}
} }
} }
if (patched > 0) { }
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2)) if (patched > 0) {
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`) writeOrganized(raw)
} logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
} }
} catch (err) { } catch (err) {
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
@@ -177,6 +178,7 @@ server.listen(PORT, async () => {
loadEnrichedSeriesData() loadEnrichedSeriesData()
.then(async seriesData => { .then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData) await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
}) })
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
@@ -245,6 +247,9 @@ server.listen(PORT, async () => {
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0) if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`) logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0)
logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`) logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
+21 -13
View File
@@ -26,34 +26,42 @@ export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size } export function cacheSize(): number { return _cache.size }
export async function githubGet(urlPath: string): Promise<any> { /**
const cached = cacheGet(urlPath) * @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
if (cached) return cached * (~5 min).
const res = await fetch(`${GITHUB_BASE}/${urlPath}`) */
export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) {
const cached = cacheGet(urlPath)
if (cached) return cached
}
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json() const data = await res.json()
cacheSet(urlPath, data) cacheSet(urlPath, data)
return data return data
} }
export async function readAvailable(): Promise<number[]> { export async function readAvailable(force = false): Promise<number[]> {
try { return await githubGet('available.json') as number[] } try { return await githubGet('available.json', force) as number[] }
catch { return [] } catch { return [] }
} }
export async function readInfohashMap(): Promise<Record<string, string>> { export async function readInfohashMap(force = false): Promise<Record<string, string>> {
try { return await githubGet('infohash_map.json') as Record<string, string> } try { return await githubGet('infohash_map.json', force) as Record<string, string> }
catch { return {} } catch { return {} }
} }
export async function readSerieData(serieId: number): Promise<any | null> { export async function readSerieData(serieId: number, force = false): Promise<any | null> {
try { return await githubGet(`series/${serieId}.json`) } try { return await githubGet(`series/${serieId}.json`, force) }
catch { return null } catch { return null }
} }
export async function loadEnrichedSeriesData(): Promise<any[]> { export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
const ids = await readAvailable() const ids = await readAvailable(force)
const allSd = await Promise.all(ids.map(id => readSerieData(id))) const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
return allSd.filter(Boolean) return allSd.filter(Boolean)
} }
+43
View File
@@ -0,0 +1,43 @@
import fs from 'fs'
import path from 'path'
import { threadId } from 'worker_threads'
import { DATA_DIR } from '../config.js'
export type Organized = Record<string, Record<string, any>>
export const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un fichier existant mais illisible lève une erreur : ne jamais repartir de {} avant d'écrire
export function readOrganized(file = ORGANIZED_PATH): Organized {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu')
return data as Organized
}
export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void {
for (const hash of Object.keys(data))
if (!data[hash] || Object.keys(data[hash]).length === 0) delete data[hash]
const tmp = `${file}.${process.pid}-${threadId}.tmp`
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8')
renameWithRetry(tmp, file)
}
// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire
export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void {
const data = readOrganized(file)
if (fn(data) !== false) writeOrganized(data, file)
}
function renameWithRetry(from: string, to: string, attempts = 5): void {
for (let i = 1; ; i++) {
try { fs.renameSync(from, to); return }
catch (err: any) {
if (i >= attempts || !['EPERM', 'EACCES', 'EBUSY'].includes(err?.code)) throw err
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * i)
}
}
}
+12
View File
@@ -53,6 +53,18 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
return 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 { export function isSynced(serieId: number): boolean {
return !!loadSynced()[serieId] return !!loadSynced()[serieId]
} }
+16 -19
View File
@@ -10,8 +10,7 @@ import path from 'path'
import { parentPort } from 'worker_threads' import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js' import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js' import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ──────────────────────────────────────────────── // ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
@@ -35,31 +34,21 @@ export interface OrgEntry {
type Organized = Record<string, Record<string, OrgEntry>> type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ───────────────────────────────────────────────── // ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try { try {
const p = path.join(DATA_DIR, 'settings.json') const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, englishDirectory: false }
return JSON.parse(fs.readFileSync(p, 'utf-8')) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } } } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
} }
// ─── Organized log ──────────────────────────────────────────── // ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Organized {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) { function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized() parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
if (!data[hash]) data[hash] = {}
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
} }
function isOrganized(hash: string, episodeId: number): boolean { function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)] return !!readOrganized()[hash]?.[String(episodeId)]
} }
// ─── Lookup torrent dans les seriesData ─────────────────────── // ─── Lookup torrent dans les seriesData ───────────────────────
@@ -302,7 +291,8 @@ function swapExtension(filename: string, ext: string): string {
} }
function seasonFolder(n: number): string { function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}` const { englishDirectory } = readSettings()
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
} }
/** /**
@@ -549,9 +539,16 @@ parentPort?.on('message', async (msg: any) => {
const torrents: any[] = msg.torrents const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? [] const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
let organized: Organized
try { organized = readOrganized() as Organized }
catch (err) {
error(`Import annulé : ${err instanceof Error ? err.message : err}`)
parentPort?.postMessage({ type: 'done' })
return
}
for (const t of torrents) { for (const t of torrents) {
if (t.state !== 'seeding') continue if (t.state !== 'seeding') continue
+176 -98
View File
@@ -8,10 +8,8 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { logger } from './logger.js' import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
import { readSettings } from './settings.js' import { readSettings } from './settings.js'
import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
export let workerRunning = false export let workerRunning = false
const _prevStates = new Map<string, string>() const _prevStates = new Map<string, string>()
@@ -30,18 +28,54 @@ export interface OrganizeResult {
errors : { file: string; error: string }[] errors : { file: string; error: string }[]
} }
interface OrgEntry { type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
at : string
season : number // ── File d'attente des workers ────────────────────────────────
episode : number let _queue: Promise<unknown> = Promise.resolve()
episode_id : number let _queued = 0
src_filename : string const _pendingManual = new Map<string, Promise<OrganizeResult>>()
dest_filename: string
dest_path : string function enqueue<T>(job: () => Promise<T>): Promise<T> {
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage _queued++
const run = _queue.then(job).finally(() => { _queued-- })
_queue = run.catch(() => {})
return run
} }
type Organized = Record<string, Record<string, OrgEntry>> function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResult) => void): Promise<void> {
return new Promise((resolve, reject) => {
workerRunning = true
const worker = new Worker(resolveWorkerPath())
let settled = false
const finish = (err?: Error) => {
if (settled) return
settled = true
workerRunning = false
worker.terminate()
err ? reject(err) : resolve()
}
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'mark') {
try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) {
logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`)
}
} else if (msg.type === 'result') {
onResult(msg)
} else if (msg.type === 'done') {
finish()
}
})
worker.on('error', err => finish(err))
worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined))
worker.postMessage({ type: 'run', torrents, seriesData })
})
}
// ── Scan initial ────────────────────────────────────────────── // ── Scan initial ──────────────────────────────────────────────
@@ -65,10 +99,8 @@ export async function scanMediaPath(
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
// Map : nom_sans_extension → { hash, srcFilename, episodeId, season, episode }
// On indexe par nom sans extension pour matcher quelle que soit l'extension sur le disque
const filenameIndex = new Map<string, { const filenameIndex = new Map<string, {
hash : string hashes : string[]
srcFilename : string srcFilename : string
episodeId : number episodeId : number
season : number season : number
@@ -84,8 +116,13 @@ export async function scanMediaPath(
const idx = (filename: string, hash: string, destFilename?: string) => { const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '') const base = filename.replace(/\.[^.]+$/, '')
if (!base) return if (!base) return
const existing = filenameIndex.get(base)
if (existing && existing.episodeId === ep.id) {
if (!existing.hashes.includes(hash)) existing.hashes.push(hash)
return
}
filenameIndex.set(base, { filenameIndex.set(base, {
hash, hashes : [hash],
srcFilename : filename, srcFilename : filename,
episodeId : ep.id, episodeId : ep.id,
season : season.season_number, season : season.season_number,
@@ -123,11 +160,12 @@ export async function scanMediaPath(
} }
} }
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return result
}
// Track les paires hash:episodeId présentes // Track les paires hash:episodeId présentes
const presentFiles = new Set<string>() const presentFiles = new Set<string>()
@@ -167,7 +205,8 @@ export async function scanMediaPath(
continue continue
} }
const { hash, srcFilename, episodeId, season, episode } = match const { hashes, srcFilename, episodeId, season, episode } = match
const hash = hashes.find(h => organized[h]?.[String(episodeId)]) ?? hashes[0]
presentFiles.add(`${hash}:${episodeId}`) presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue if (organized[hash]?.[String(episodeId)]) continue
@@ -190,11 +229,17 @@ export async function scanMediaPath(
walk(mediaPath) walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque // Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`)) const allEpisodeIds = new Set(
[...filenameIndex.values()].flatMap(v => v.hashes.map(h => `${h}:${v.episodeId}`))
)
let removed = 0 let removed = 0
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
if (mediaEmpty) break
for (const episodeId of Object.keys(episodes)) { for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) { if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId] delete organized[hash][episodeId]
@@ -207,7 +252,7 @@ export async function scanMediaPath(
// Désimport automatique des fichiers manquants sur le disque // Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0 let autoRemoved = 0
if (autoUnimportMissing) { if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) { for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path const destPath = entry?.dest_path
@@ -222,7 +267,7 @@ export async function scanMediaPath(
} }
if (result.added > 0 || removed > 0 || autoRemoved > 0) { if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') writeOrganized(organized, organizedPath)
} }
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`) logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
@@ -243,8 +288,8 @@ export async function autoOrganizeAll(
return return
} }
if (workerRunning) { if (_queued > 0) {
logger.debug('organize', 'Worker déjà en cours — skip') logger.debug('organize', 'Import déjà en cours — skip')
return return
} }
@@ -268,11 +313,12 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state) for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized() }
if (fs.existsSync(ORGANIZED_PATH)) catch (err) {
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
} catch {} return
}
const hasUnorganized = torrents.some(t => const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()] t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
@@ -290,13 +336,8 @@ export async function autoOrganizeAll(
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`) logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
} }
workerRunning = true try {
const worker = new Worker(resolveWorkerPath()) await enqueue(() => runWorker(torrents, seriesData, msg => {
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
} else if (msg.type === 'result') {
if (msg.errors.length > 0) { if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`) logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) { for (const e of msg.errors) {
@@ -316,24 +357,11 @@ export async function autoOrganizeAll(
errors : msg.errors.length, errors : msg.errors.length,
errorFiles: msg.errors, errorFiles: msg.errors,
}) })
} else if (msg.type === 'done') { }))
logger.debug('organize', 'Worker terminé') logger.debug('organize', 'Worker terminé')
workerRunning = false } catch (err) {
worker.terminate() logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
} }
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur : ${err.message}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
worker.postMessage({ type: 'run', torrents, seriesData })
} }
// ── Migration des IDs épisodes ──────────────────────────────── // ── Migration des IDs épisodes ────────────────────────────────
@@ -368,11 +396,12 @@ export async function migrateOrganizedEpisodeIds(
} }
// 2. Charger organized.json // 2. Charger organized.json
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { updated: 0, orphaned: 0 } } return { updated: 0, orphaned: 0 }
}
let updated = 0 let updated = 0
let orphaned = 0 let orphaned = 0
@@ -422,7 +451,7 @@ export async function migrateOrganizedEpisodeIds(
} }
if (changed) { if (changed) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') writeOrganized(organized, organizedPath)
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`) logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else { } else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`) logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
@@ -431,6 +460,58 @@ export async function migrateOrganizedEpisodeIds(
return { updated, orphaned } return { updated, orphaned }
} }
// ── Déduplication : un seul enregistrement par épisode ────────
export function dedupeOrganizedEpisodes(
organizedPath: string,
): { removed: number } {
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Dédoublonnage annulé : ${err instanceof Error ? err.message : err}`)
return { removed: 0 }
}
// episode_id → [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
if (!byEpisode.has(episodeId)) byEpisode.set(episodeId, [])
byEpisode.get(episodeId)!.push({ hash, entry })
}
}
let removed = 0
for (const [episodeId, candidates] of byEpisode) {
if (candidates.length < 2) continue
const score = (c: { entry: any }) => {
const exists = c.entry?.dest_path ? fs.existsSync(c.entry.dest_path) : false
const at = Date.parse(c.entry?.at ?? '') || 0
return { exists, at }
}
const sorted = [...candidates].sort((a, b) => {
const sa = score(a), sb = score(b)
if (sa.exists !== sb.exists) return sa.exists ? -1 : 1
return sb.at - sa.at
})
const keep = sorted[0]
for (const c of sorted.slice(1)) {
delete (organized[c.hash] as any)[episodeId]
removed++
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (conservé sous ${keep.hash.slice(0, 8)}…)`)
}
}
if (removed > 0) {
writeOrganized(organized, organizedPath)
logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
}
return { removed }
}
// ── Sync des noms de fichiers après mise à jour du scraper ──── // ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges( export async function syncFilenameChanges(
@@ -438,11 +519,12 @@ export async function syncFilenameChanges(
organizedPath: string, organizedPath: string,
): Promise<{ renamed: number; errors: number }> { ): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
let organized: Organized = {} let organized: Organized
try { try { organized = readOrganized(organizedPath) }
if (fs.existsSync(organizedPath)) catch (err) {
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
} catch { return { renamed: 0, errors: 0 } } return { renamed: 0, errors: 0 }
}
let renamed = 0 let renamed = 0
let errors = 0 let errors = 0
@@ -506,7 +588,7 @@ export async function syncFilenameChanges(
} }
if (changed) if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0) if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`) logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
@@ -523,20 +605,24 @@ export async function organizeTorrent(
seriesData: any[], seriesData: any[],
files? : { name?: string; progress: number; priority?: number }[], files? : { name?: string; progress: number; priority?: number }[],
): Promise<OrganizeResult> { ): Promise<OrganizeResult> {
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`) const key = hash.toLowerCase()
const pending = _pendingManual.get(key)
if (pending) {
logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`)
return pending
}
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : 'lancé'} pour "${name}" (${hash})`)
return new Promise((resolve, reject) => { // files : progression par fichier récupérée depuis le client torrent.
const worker = new Worker(resolveWorkerPath()) // Permet au worker de ne traiter que les fichiers à 100% et d'éviter
// files : progression par fichier récupérée depuis le client torrent. // les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres. const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => { const job = enqueue(async () => {
if (msg.type === 'log') { _pendingManual.delete(key)
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) try {
} else if (msg.type === 'result') { await runWorker([fakeTorrent], seriesData, msg => {
result.total = msg.total result.total = msg.total
result.skipped = msg.skipped result.skipped = msg.skipped
result.done = msg.done result.done = msg.done
@@ -549,21 +635,13 @@ export async function organizeTorrent(
} else { } else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`) logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
} }
} else if (msg.type === 'done') { })
worker.terminate() } catch (err) {
resolve(result) logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
} throw err
}) }
return result
worker.on('error', (err) => {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
worker.terminate()
reject(err)
})
worker.on('exit', (code) => {
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
}) })
_pendingManual.set(key, job)
return job
} }
+81 -55
View File
@@ -1,9 +1,8 @@
import { Router } from 'express' import { Router } from 'express'
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js' import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js' import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
@@ -11,20 +10,10 @@ import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js' import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js' import { dispatchRemove } from '../torrent-clients/index.js'
import { readRequests, deleteRequest } from '../requests.js' import { readRequests, deleteRequest } from '../requests.js'
import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js'
const router = Router() const router = Router()
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
function loadOrganizedJson(): Record<string, Record<string, any>> {
try { if (fs.existsSync(ORGANIZED_PATH)) return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
return {}
}
function saveOrganizedJson(data: Record<string, Record<string, any>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
// ── Import manuel ────────────────────────────────────────────── // ── Import manuel ──────────────────────────────────────────────
router.post('/manual-import', requireAuth, async (req, res) => { router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body const { serie_id, items } = req.body
@@ -32,7 +21,12 @@ router.post('/manual-import', requireAuth, async (req, res) => {
res.status(400).json({ error: 'serie_id et items requis' }); return res.status(400).json({ error: 'serie_id et items requis' }); return
} }
const { mediaPath, organizeMode, nfoSupport } = readSettings() const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson() let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
// Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
let sd = await readSerieData(Number(serie_id)) let sd = await readSerieData(Number(serie_id))
if (!sd) { if (!sd) {
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet // readSerieData ne couvre que le cache local — fallback sur le catalogue complet
@@ -78,13 +72,15 @@ router.post('/manual-import', requireAuth, async (req, res) => {
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide) // sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
for (const [h, eps] of Object.entries(organized)) { for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) { if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`) logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
}
} }
if (Object.keys(organized[h]).length === 0) delete organized[h]
} }
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) delete eps[epId]
})
fs.mkdirSync(destDir, { recursive: true }) fs.mkdirSync(destDir, { recursive: true })
if (file_path !== destPath) { if (file_path !== destPath) {
@@ -96,15 +92,17 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.unlinkSync(destPath) fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`) logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé // Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
for (const [h, eps] of Object.entries(organized)) { for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath) { if (entry?.dest_path === destPath)
delete (organized[h] as any)[epId]
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`) logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
}
} }
if (Object.keys(organized[h]).length === 0) delete organized[h]
} }
apply(data => {
for (const eps of Object.values(data))
for (const [epId, entry] of Object.entries(eps))
if (entry?.dest_path === destPath) delete eps[epId]
})
} }
if (isInSeriePath && !fs.existsSync(destPath)) { if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath) fs.renameSync(file_path, destPath)
@@ -125,12 +123,12 @@ router.post('/manual-import', requireAuth, async (req, res) => {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`) logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
} }
const torrentHash = String(hash || '').toLowerCase() || 'manual' const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {} const entry = {
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number, at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id, episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath, src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
} }
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id) done.push(episode_id)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -138,7 +136,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`) logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
} }
} }
saveOrganizedJson(organized) if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) {
logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
}
}
if (nfoSupport && done.length > 0) { if (nfoSupport && done.length > 0) {
const gitlabTitle = getGitlabTitle(serieTitle) const gitlabTitle = getGitlabTitle(serieTitle)
@@ -175,7 +179,7 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
try { try {
const sd = await readSerieData(serieId) const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson() const organized = readOrganized()
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const result: Record<string, any> = {} const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
@@ -208,9 +212,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return } if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id)) const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
let foundEp: any = null let foundEp: any = null
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
@@ -238,7 +244,7 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`) if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized) writeOrganized(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`) logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName }) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) { } catch (err) {
@@ -252,9 +258,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
router.delete('/organized/:serieId', requireAuth, async (req, res) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId)) const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const episodeIds = new Set<string>() const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id)) for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -279,7 +287,7 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
} }
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue if (hash === 'manual') continue
@@ -312,9 +320,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId) const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId)) const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId) const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? []) const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
@@ -333,7 +343,7 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
} }
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
saveOrganizedJson(organized) writeOrganized(organized)
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue if (hash === 'manual') continue
@@ -348,27 +358,40 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => { router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId) const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson() let organized: Organized
let foundHash: string | null = null try { organized = readOrganized() }
let foundEntry: any = null catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break } if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
} }
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return } if (found.length === 0) { res.status(404).json({ error: 'Épisode non importé' }); return }
try { try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) { const deletedPaths = new Set<string>()
fs.unlinkSync(foundEntry.dest_path) if (deleteFile) {
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`) for (const { entry } of found) {
const p = entry?.dest_path
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p)
deletedPaths.add(p)
logger.info('api', `Désimport + suppression fichier : "${p}"`)
}
} }
delete organized[foundHash][episodeId] const emptyHashes: string[] = []
const torrentEmpty = Object.keys(organized[foundHash]).length === 0 for (const { hash } of found) {
if (torrentEmpty) delete organized[foundHash] delete organized[hash][episodeId]
saveOrganizedJson(organized) if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
// Si plus aucun épisode de ce torrent n'est importé → retirer le torrent du client
if (deleteFile && torrentEmpty && foundHash !== 'manual') {
dispatchRemove(foundHash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${foundHash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
} }
logger.info('api', `Désimport ep ${episodeId}${deleteFile && torrentEmpty ? ` + torrent ${foundHash.slice(0, 8)}… retiré du client` : ''}`) writeOrganized(organized)
// Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true }) res.json({ ok: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -381,7 +404,7 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
router.get('/organized-summary', requireAuth, async (_req, res) => { router.get('/organized-summary', requireAuth, async (_req, res) => {
try { try {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson() const organized = readOrganized()
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const result: any[] = [] const result: any[] = []
for (const sd of seriesData) { for (const sd of seriesData) {
@@ -421,15 +444,18 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
}) })
// ── Rename en masse ──────────────────────────────────────────── // ── Rename en masse ────────────────────────────────────────────
router.post('/rename-all', requireAuth, async (req, res) => { router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id } = req.body const { serie_id, serie_ids } = req.body
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
const organized = loadOrganizedJson()
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
const done: number[] = [] const done: number[] = []
const errors: { episode_id: number; error: string }[] = [] const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) { for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue if (onlyIds && !onlyIds.has(sd.id)) continue
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
let orgEntry: any = null let orgEntry: any = null
@@ -465,7 +491,7 @@ router.post('/rename-all', requireAuth, async (req, res) => {
} }
} }
} }
saveOrganizedJson(organized) writeOrganized(organized)
res.json({ ok: true, done: done.length, errors }) res.json({ ok: true, done: done.length, errors })
}) })
+14 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import { requireAuth } from '../auth.js' import { requireAuth, requireAdmin } from '../auth.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { loadSynced, setSync, isSynced, runRssSync } from '../lib/rss-sync.js' import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync } from '../lib/rss-sync.js'
const router = Router() const router = Router()
@@ -21,6 +21,18 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => {
} }
}) })
/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && s.name)
if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return }
const { changed, map } = setSyncBulk(series, enabled)
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`)
res.json({ ok: true, changed, total: Object.keys(map).length })
})
/** GET /api/rss-sync/:id → statut sync pour une série */ /** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => { router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) const serieId = Number(req.params.id)
+10 -4
View File
@@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { dispatchList } from '../torrent-clients/index.js' import { dispatchList } from '../torrent-clients/index.js'
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js' import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
import { loadSynced } from '../lib/rss-sync.js'
import { import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode, fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
let organized: Record<string, Record<string, any>> = {} let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>() const activeTorrents = 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 && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {} const clientHashes = 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) continue; clientHashes.add(t.hash.toLowerCase()); if (t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
const organizedEpisodeIds = new Set(Object.values(organized).flatMap(eps => Object.keys(eps)))
const synced = loadSynced()
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map<number, any>() const serieDataMap = new Map<number, any>()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) })) await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
res.json({ series: seriesRaw.map((serie: any) => { res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null const serieData = serieDataMap.get(serie.id) ?? null
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
})}) })})
} catch (err) { } catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`) logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
+2 -1
View File
@@ -10,7 +10,7 @@ router.get('/settings', requireAuth, (_req, res) => {
}) })
router.post('/settings', requireAuth, (req, res) => { router.post('/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers } = req.body const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing, requestAutoDownloadUsers, englishDirectory } = req.body
const patch: Record<string, any> = {} const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath if (completePath !== undefined) patch.completePath = completePath
@@ -22,6 +22,7 @@ router.post('/settings', requireAuth, (req, res) => {
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
const updated = writeSettings(patch) const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour') logger.info('api', 'Paramètres mis à jour')
res.json(updated) res.json(updated)
+21 -5
View File
@@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js' import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js' import { systemInfo } from '../system.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js' import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js' import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js' import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js' import { recentOrganized } from '../lib/notifs.js'
@@ -126,18 +126,34 @@ router.get('/torrents/status', requireAuth, async (_req, res) => {
} catch { res.json({ exists: false, count: 0, empty: true }) } } catch { res.json({ exists: false, count: 0, empty: true }) }
}) })
router.post('/update', requireAuth, async (_req, res) => { router.post('/update', requireAuth, async (req, res) => {
const force = req.body?.force === true
const organizedPath = path.join(DATA_DIR, 'organized.json')
try { try {
cacheClear() cacheClear()
logger.info('api', 'Cache GitHub vidé — rechargement forcé') logger.info('api', force
const availableIds = await readAvailable() ? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés'
: 'Cache GitHub vidé — rechargement forcé')
const availableIds = await readAvailable(force)
if (!Array.isArray(availableIds)) throw new Error('available.json invalide') if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
if (force) {
const seriesData = await loadEnrichedSeriesData(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return
}
res.json({ ok: true, count: availableIds.length }) res.json({ ok: true, count: availableIds.length })
;(async () => { ;(async () => {
try { try {
const seriesData = await loadEnrichedSeriesData() const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json')) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`) logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) { } catch (err) {
+2
View File
@@ -16,6 +16,7 @@ export interface Settings {
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096) jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync jellyfinAdminToken : string // Token admin Jellyfin pour la sync
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
} }
const defaults: Settings = { const defaults: Settings = {
@@ -30,6 +31,7 @@ const defaults: Settings = {
requestAutoDownloadUsers: [], requestAutoDownloadUsers: [],
jellyfinUrl : '', jellyfinUrl : '',
jellyfinAdminToken : '', jellyfinAdminToken : '',
englishDirectory : false,
} }
export function readSettings(): Settings { export function readSettings(): Settings {
+13 -10
View File
@@ -8,7 +8,8 @@
* Progress : float 0–100 (déjà en pourcentage, pas 0–1) * Progress : float 0–100 (déjà en pourcentage, pas 0–1)
*/ */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── Session cache ───────────────────────────────────────────────────────────── // ─── Session cache ─────────────────────────────────────────────────────────────
@@ -18,13 +19,13 @@ import { logger } from '../logger.js'
interface SessionEntry { cookie: string; expires: number } interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>() const _sessions = new Map<string, SessionEntry>()
function sessionKey(config: Record<string, string | number>): string { function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}` return `${config.url}::${config.password}`
} }
// ─── Helpers HTTP ────────────────────────────────────────────────────────────── // ─── Helpers HTTP ──────────────────────────────────────────────────────────────
function delugeUrl(config: Record<string, string | number>): string { function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json' return String(config.url ?? '').replace(/\/+$/, '') + '/json'
} }
@@ -34,12 +35,13 @@ function parseCookie(raw: string): string {
} }
async function delugeRPC( async function delugeRPC(
config : ClientConfig,
url : string, url : string,
method : string, method : string,
params : unknown[], params : unknown[],
cookie : string, cookie : string,
): Promise<{ result: any; newCookie: string | null }> { ): Promise<{ result: any; newCookie: string | null }> {
const res = await fetch(url, { const res = await clientFetch(config, url, {
method : 'POST', method : 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -68,9 +70,9 @@ async function delugeRPC(
// ─── Login ───────────────────────────────────────────────────────────────────── // ─── Login ─────────────────────────────────────────────────────────────────────
async function delugeLogin(config: Record<string, string | number>): Promise<string> { async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config) const url = delugeUrl(config)
const res = await fetch(url, { const res = await clientFetch(config, url, {
method : 'POST', method : 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }), body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
@@ -94,7 +96,7 @@ async function delugeLogin(config: Record<string, string | number>): Promise<str
// ─── Call avec gestion de session ───────────────────────────────────────────── // ─── Call avec gestion de session ─────────────────────────────────────────────
async function delugeCall( async function delugeCall(
config : Record<string, string | number>, config : ClientConfig,
method : string, method : string,
params : unknown[] = [], params : unknown[] = [],
): Promise<any> { ): Promise<any> {
@@ -110,7 +112,7 @@ async function delugeCall(
} }
try { try {
const { result, newCookie } = await delugeRPC(url, method, params, entry.cookie) const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
// Mise à jour du cookie si Deluge en renvoie un nouveau // Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) { if (newCookie) {
entry.cookie = newCookie entry.cookie = newCookie
@@ -125,7 +127,7 @@ async function delugeCall(
const cookie = await delugeLogin(config) const cookie = await delugeLogin(config)
const fresh = { cookie, expires: Date.now() + 30 * 60_000 } const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
_sessions.set(key, fresh) _sessions.set(key, fresh)
const { result } = await delugeRPC(url, method, params, cookie) const { result } = await delugeRPC(config, url, method, params, cookie)
return result return result
} }
throw err throw err
@@ -178,7 +180,7 @@ function flattenDelugeFiles(
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip). // les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
async function applyFilePriority( async function applyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -211,6 +213,7 @@ const delugeDriver: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
+53 -11
View File
@@ -7,18 +7,22 @@
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { Agent, fetch as undiciFetch } from 'undici'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
/** Config d'un client torrent (les champs `boolean` sont stockés en booléens). */
export type ClientConfig = Record<string, string | number | boolean>
export interface FieldDef { export interface FieldDef {
key : string key : string
label : string label : string
type : 'text' | 'password' | 'number' | 'url' type : 'text' | 'password' | 'number' | 'url' | 'boolean'
placeholder?: string placeholder?: string
required : boolean required : boolean
default? : string | number default? : string | number | boolean
} }
export interface TorrentClientDefinition { export interface TorrentClientDefinition {
@@ -34,7 +38,7 @@ export interface SavedClient {
uuid : string uuid : string
name : string name : string
type : string type : string
config: Record<string, string | number> config: ClientConfig
} }
export interface TorrentFileProgress { export interface TorrentFileProgress {
@@ -70,12 +74,50 @@ export interface DownloadOptions {
export interface TorrentClientDriver { export interface TorrentClientDriver {
definition : TorrentClientDefinition definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }> test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }> healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void> add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]> list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void> remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]> getFiles ?: (config: ClientConfig, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Certificats TLS ──────────────────────────────────────────────────────────
// Option "ignoreCertificateErrors" (par client) : accepte les certificats
// auto-signés/invalides. Node → Agent undici (cf. plex.ts) ; Bun → option
// `tls` de son fetch natif (l'Agent undici n'y est pas honoré).
let _insecureAgent: Agent | null = null
function insecureAgent(): Agent {
if (!_insecureAgent) _insecureAgent = new Agent({ connect: { rejectUnauthorized: false } })
return _insecureAgent
}
export function ignoreCertificateErrors(config: ClientConfig): boolean {
const v = config.ignoreCertificateErrors
return v === true || v === 'true' || v === 1
}
// Option `tls` propre au fetch de Bun, absente des types standards.
interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean }
}
const isBun = 'bun' in process.versions
/** fetch du client : accepte les certificats invalides si l'option est active. */
export function clientFetch(
config : ClientConfig,
url : string,
init : RequestInit = {},
): Promise<Response> {
if (!ignoreCertificateErrors(config)) return fetch(url, init)
if (isBun) {
const bunInit: BunRequestInit = { ...init, tls: { rejectUnauthorized: false } }
return fetch(url, bunInit)
}
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
} }
// ─── Registry ───────────────────────────────────────────────────────────────── // ─── Registry ─────────────────────────────────────────────────────────────────
@@ -174,7 +216,7 @@ export function listClients(): SavedClient[] {
return loadClients() return loadClients()
} }
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient { export function addClient(name: string, type: string, config: ClientConfig): SavedClient {
const clients = loadClients() const clients = loadClients()
const client: SavedClient = { uuid: randomUUID(), name, type, config } const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client) clients.push(client)
@@ -198,7 +240,7 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid) return loadClients().find(c => c.uuid === uuid)
} }
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null { export function updateClient(uuid: string, name: string, type: string, config: ClientConfig): SavedClient | null {
const clients = loadClients() const clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid) const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null if (index === -1) return null
+24 -22
View File
@@ -3,7 +3,8 @@
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication) * Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/ */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.) // qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
@@ -27,7 +28,7 @@ function mapState(state: string): TorrentInfo['state'] {
} }
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID // Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> { async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
if (config.apiKey) { if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` } return { Authorization: `Bearer ${config.apiKey}` }
} }
@@ -35,12 +36,12 @@ async function qbAuth(config: Record<string, string | number>): Promise<Record<s
return { Cookie: cookie } return { Cookie: cookie }
} }
async function qbLogin(config: Record<string, string | number>): Promise<string> { async function qbLogin(config: ClientConfig): Promise<string> {
const form = new URLSearchParams() const form = new URLSearchParams()
form.append('username', String(config.username ?? '')) form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? '')) form.append('password', String(config.password ?? ''))
const res = await fetch(`${config.url}/api/v2/auth/login`, { const res = await clientFetch(config, `${config.url}/api/v2/auth/login`, {
method : 'POST', method : 'POST',
body : form, body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -55,9 +56,9 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
return cookie return cookie
} }
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> { async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try { try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: authHeaders, headers: authHeaders,
}) })
if (!res.ok) return false if (!res.ok) return false
@@ -73,7 +74,7 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
* puis applique les priorités (désactive tout sauf la cible). * puis applique les priorités (désactive tout sauf la cible).
*/ */
async function qbApplyFilePriority( async function qbApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -82,7 +83,7 @@ async function qbApplyFilePriority(
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500)) await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try { try {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH, headers: authH,
}) })
if (!res.ok) continue if (!res.ok) continue
@@ -105,13 +106,13 @@ async function qbApplyFilePriority(
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex) const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) { if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' }) const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers }) const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue } if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
} }
} }
// Activer le fichier cible (dans tous les cas) // Activer le fichier cible (dans tous les cas)
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, { const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST', method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }), body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers, headers,
@@ -119,7 +120,7 @@ async function qbApplyFilePriority(
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue } if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Vérification : le fichier cible est bien à priorité > 0 // Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH }) const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) { if (verif.ok) {
const verifiedFiles: any[] = await verif.json() const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex] const target = verifiedFiles[fileIndex]
@@ -152,13 +153,14 @@ const QB: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
async test(config) { async test(config) {
try { try {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH }) const res = await clientFetch(config, `${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`) logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
@@ -172,7 +174,7 @@ const QB: TorrentClientDriver = {
async healthcheck(config) { async healthcheck(config) {
try { try {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { const res = await clientFetch(config, `${config.url}/api/v2/app/version`, {
headers: authH, headers: authH,
}) })
if (!res.ok) return { online: false } if (!res.ok) return { online: false }
@@ -190,7 +192,7 @@ const QB: TorrentClientDriver = {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const params = new URLSearchParams() const params = new URLSearchParams()
if (category) params.set('category', category) if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH, headers: authH,
}) })
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`) if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
@@ -200,7 +202,7 @@ const QB: TorrentClientDriver = {
const fileMap = new Map<string, any[]>() const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => { await Promise.all(data.map(async (t: any) => {
try { try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, { const r = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: authH, headers: authH,
}) })
if (r.ok) fileMap.set(t.hash, await r.json()) if (r.ok) fileMap.set(t.hash, await r.json())
@@ -237,7 +239,7 @@ const QB: TorrentClientDriver = {
async getFiles(config, hash) { async getFiles(config, hash) {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH, headers: authH,
}) })
if (!res.ok) return [] if (!res.ok) return []
@@ -275,7 +277,7 @@ const QB: TorrentClientDriver = {
let knownHashes: Set<string> = new Set() let knownHashes: Set<string> = new Set()
if (!hash) { if (!hash) {
try { try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH }) const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase())) if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {} } catch {}
} }
@@ -292,7 +294,7 @@ const QB: TorrentClientDriver = {
params.set('urls', url) params.set('urls', url)
if (config.category) params.set('category', String(config.category)) if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath)) if (config.savePath) params.set('savepath', String(config.savePath))
res = await fetch(`${config.url}/api/v2/torrents/add`, { res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params, method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
@@ -306,7 +308,7 @@ const QB: TorrentClientDriver = {
if (config.category) form.append('category', String(config.category)) if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath)) if (config.savePath) form.append('savepath', String(config.savePath))
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent') form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH }) res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
} }
const text = await res.text() const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`) if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
@@ -325,7 +327,7 @@ const QB: TorrentClientDriver = {
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
try { try {
const ah = await qbAuth(config) const ah = await qbAuth(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah }) const lr = await clientFetch(config, `${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase())) const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue if (!newT) continue
@@ -347,7 +349,7 @@ const QB: TorrentClientDriver = {
if (config.category) params.set('category', String(config.category)) if (config.category) params.set('category', String(config.category))
if (config.savePath) params.set('savepath', String(config.savePath)) if (config.savePath) params.set('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, {
method: 'POST', body: params, method: 'POST', body: params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
@@ -359,7 +361,7 @@ const QB: TorrentClientDriver = {
async remove(config, hash, deleteFiles = false) { async remove(config, hash, deleteFiles = false) {
const authH = await qbAuth(config) const authH = await qbAuth(config)
const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' }) const params = new URLSearchParams({ hashes: hash, deleteFiles: deleteFiles ? 'true' : 'false' })
const res = await fetch(`${config.url}/api/v2/torrents/delete`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/delete`, {
method : 'POST', method : 'POST',
body : params, body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
+3 -3
View File
@@ -13,12 +13,12 @@
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre). * (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/ */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0' const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) { function rdHeaders(config: ClientConfig) {
return { return {
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@@ -26,7 +26,7 @@ function rdHeaders(config: Record<string, string | number>) {
} }
async function rdFetch( async function rdFetch(
config : Record<string, string | number>, config : ClientConfig,
method : 'GET' | 'POST' | 'DELETE', method : 'GET' | 'POST' | 'DELETE',
path : string, path : string,
body ?: URLSearchParams, body ?: URLSearchParams,
+8 -6
View File
@@ -5,7 +5,8 @@
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/ */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── XML-RPC helpers ────────────────────────────────────────── // ─── XML-RPC helpers ──────────────────────────────────────────
@@ -55,7 +56,7 @@ function parseXmlValue(node: Element): any {
} }
async function rpcCall( async function rpcCall(
config : Record<string, string | number>, config : ClientConfig,
method : string, method : string,
params : any[] = [], params : any[] = [],
): Promise<any> { ): Promise<any> {
@@ -69,7 +70,7 @@ async function rpcCall(
const rpcPath = String(config.rpcPath || '/RPC2') const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}` const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await fetch(url, { method: 'POST', headers, body }) const res = await clientFetch(config, url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text() const text = await res.text()
@@ -109,7 +110,7 @@ function parseInnerValue(inner: string): any {
// multicall pour récupérer plusieurs champs en une requête // multicall pour récupérer plusieurs champs en une requête
async function d_multicall( async function d_multicall(
config : Record<string, string | number>, config : ClientConfig,
view : string, view : string,
methods: string[], methods: string[],
): Promise<any[][]> { ): Promise<any[][]> {
@@ -118,7 +119,7 @@ async function d_multicall(
return Array.isArray(result) ? result : [] return Array.isArray(result) ? result : []
} }
async function rtTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> { async function rtTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try { try {
const rows = await d_multicall(config, 'main', ['d.hash']) const rows = await d_multicall(config, 'main', ['d.hash'])
return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase()) return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase())
@@ -134,7 +135,7 @@ async function rtTorrentExists(config: Record<string, string | number>, hash: st
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence. * On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/ */
async function rtApplyFilePriority( async function rtApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -192,6 +193,7 @@ const RT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
+10 -8
View File
@@ -4,7 +4,8 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
@@ -83,7 +84,7 @@ function mapState(status: string): TorrentInfo['state'] {
// ─── Auth ────────────────────────────────────────────────────────────────────── // ─── Auth ──────────────────────────────────────────────────────────────────────
async function dsLogin(config: Record<string, string | number>): Promise<string> { async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({ const params = new URLSearchParams({
api : 'SYNO.API.Auth', api : 'SYNO.API.Auth',
version: '3', version: '3',
@@ -93,7 +94,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
session: 'DownloadStation', session: 'DownloadStation',
format : 'sid', format : 'sid',
}) })
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`) const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`) if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
@@ -101,7 +102,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
} }
async function dsRequest( async function dsRequest(
config : Record<string, string | number>, config : ClientConfig,
api : string, api : string,
method : string, method : string,
version: string, version: string,
@@ -109,7 +110,7 @@ async function dsRequest(
sid : string, sid : string,
): Promise<any> { ): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra }) const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`) if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
@@ -131,6 +132,7 @@ const DS: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
@@ -155,7 +157,7 @@ const DS: TorrentClientDriver = {
method : 'getinfo', method : 'getinfo',
_sid : sid, _sid : sid,
}) })
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`) const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/info.cgi?${params}`)
if (!res.ok) return { online: false } if (!res.ok) return { online: false }
const data = await res.json() const data = await res.json()
if (!data.success) return { online: false } if (!data.success) return { online: false }
@@ -262,7 +264,7 @@ const DS: TorrentClientDriver = {
uri : url, uri : url,
}) })
if (withDestination && config.savePath) params.append('destination', String(config.savePath)) if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() return res.json()
} }
@@ -355,7 +357,7 @@ const DS: TorrentClientDriver = {
force_complete: 'false', force_complete: 'false',
_sid : sid, _sid : sid,
}) })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`) logger.info('synology-ds', `Torrent ${found.id} supprimé`)
}, },
+7 -5
View File
@@ -2,7 +2,8 @@
* Transmission Driver * Transmission Driver
*/ */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] { function mapState(status: number): TorrentInfo['state'] {
@@ -17,7 +18,7 @@ function mapState(status: number): TorrentInfo['state'] {
} }
async function trRequest( async function trRequest(
config: Record<string, string | number>, config: ClientConfig,
method: string, method: string,
args: Record<string, unknown> = {}, args: Record<string, unknown> = {},
sessionId = '' sessionId = ''
@@ -31,7 +32,7 @@ async function trRequest(
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`) headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
} }
const res = await fetch(`${config.url}/transmission/rpc`, { const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
method : 'POST', method : 'POST',
headers, headers,
body : JSON.stringify({ method, arguments: args }), body : JSON.stringify({ method, arguments: args }),
@@ -52,7 +53,7 @@ async function trRequest(
return { ...data, sessionId } return { ...data, sessionId }
} }
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> { async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] }) const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? [] const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
@@ -63,7 +64,7 @@ async function trGetIdByHash(config: Record<string, string | number>, hash: stri
* puis applique les priorités : fichier cible voulu, tous les autres ignorés. * puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/ */
async function trApplyFilePriority( async function trApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -102,6 +103,7 @@ const TR: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
+12 -10
View File
@@ -2,7 +2,8 @@
* uTorrent Driver * uTorrent Driver
*/ */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// uTorrent status flags (bitmask) : // uTorrent status flags (bitmask) :
@@ -21,7 +22,7 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session // uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string } interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> { async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
try { try {
const session = await utGetSession(config) const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session) const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
@@ -35,7 +36,7 @@ async function utTorrentExists(config: Record<string, string | number>, hash: st
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal). * tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/ */
async function utApplyFilePriority( async function utApplyFilePriority(
config : Record<string, string | number>, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
@@ -54,13 +55,13 @@ async function utApplyFilePriority(
// Passer tous les fichiers à priorité 0 (skip) // Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' }) const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i)) for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, { await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
}) })
// Activer le fichier cible (priorité 2 = normal) // Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) }) const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, { await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
}) })
@@ -71,13 +72,13 @@ async function utApplyFilePriority(
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`) throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
} }
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> { async function utGetSession(config: ClientConfig): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = { const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`, 'Authorization': `Basic ${auth}`,
} }
const res = await fetch(`${config.url}/gui/token.html`, { headers }) const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`) if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const text = await res.text() const text = await res.text()
@@ -90,14 +91,14 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
} }
async function utRequest( async function utRequest(
config : Record<string, string | number>, config : ClientConfig,
params : Record<string, string>, params : Record<string, string>,
session: UTSession, session: UTSession,
): Promise<any> { ): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params }) const qs = new URLSearchParams({ token: session.token, ...params })
const res = await fetch(`${config.url}/gui/?${qs}`, { const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
headers: { headers: {
'Authorization': `Basic ${auth}`, 'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie, 'Cookie' : session.cookie,
@@ -142,6 +143,7 @@ const UT: TorrentClientDriver = {
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
@@ -213,7 +215,7 @@ const UT: TorrentClientDriver = {
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url }) const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath)) if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, { const res = await clientFetch(config, `${config.url}/gui/?${qs}`, {
method : 'GET', method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
}) })
+8 -6
View File
@@ -618,19 +618,21 @@ function _parseEpChap(plot: string | null | undefined): {
const seasonMatch = text.match(/saison\s+(\d+)/i) const seasonMatch = text.match(/saison\s+(\d+)/i)
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
const chapMatch = text.match( const chapMatch = text.match(
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i /chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
) )
const episodes = epMatch const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`) ? `${season ? `S${season} · ` : ''}${
epMatch[2]
? `Ép. ${epMatch[1]}–${epMatch[2]}`
: `Ép. ${epMatch[1]}`
}`
: null : null
const season = epMatch?.[3]
? Number(epMatch[3])
: seasonMatch
? Number(seasonMatch[1])
: null
const chapters = chapMatch const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`) ? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
+16 -1
View File
@@ -75,6 +75,18 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Sélection multiple -->
<button
v-if="selectable"
@click="emit('update:selecting', !selecting)"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
:class="selecting ? 'border-accent text-accent' : ''"
title="Sélectionner plusieurs séries"
>
<ListChecks :size="15" />
<span class="hidden md:inline text-xs">Sélection</span>
</button>
</div> </div>
<!-- Ligne 2 : tri + taille + count --> <!-- Ligne 2 : tri + taille + count -->
@@ -121,7 +133,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next' import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
defineProps<{ defineProps<{
@@ -136,6 +148,8 @@ defineProps<{
filtersImport : { label: string; value: string; color: string }[] filtersImport : { label: string; value: string; color: string }[]
sortOptions : { label: string; value: string }[] sortOptions : { label: string; value: string }[]
legend : { label: string; color: string }[] legend : { label: string; color: string }[]
selectable? : boolean
selecting? : boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -143,6 +157,7 @@ const emit = defineEmits<{
'update:activeFilter': [value: string] 'update:activeFilter': [value: string]
'update:activeSort' : [value: string] 'update:activeSort' : [value: string]
'update:posterSize' : [value: string] 'update:posterSize' : [value: string]
'update:selecting' : [value: boolean]
}>() }>()
const filterOpen = ref(false) const filterOpen = ref(false)
+4
View File
@@ -46,6 +46,10 @@ export interface Serie {
status: string | null status: string | null
torrent_count: number torrent_count: number
has_torrents: boolean has_torrents: boolean
download_state: 'none' | 'downloading' | 'partial' | 'complete'
in_client: boolean
has_files: boolean
rss_synced: boolean
} }
export interface SerieDetail { export interface SerieDetail {
+54 -29
View File
@@ -405,6 +405,16 @@ function integraleGroupLabels(): string[] {
} }
// Épisodes couverts par une intégrale donnée (hash commun) // Épisodes couverts par une intégrale donnée (hash commun)
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
const organizedEpisodeIds = computed(() => {
const ids = new Set<number>()
for (const season of data.value?.seasons ?? [])
for (const ep of season.episodes)
if (ep.organized) ids.add(ep.id)
return ids
})
function integraleEpisodeCoverage(integraleIndex: number): Set<number> { function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
const covered = new Set<number>() const covered = new Set<number>()
const t = data.value?.torrents_integrale[integraleIndex] const t = data.value?.torrents_integrale[integraleIndex]
@@ -503,6 +513,8 @@ async function fetchActiveDownloads() {
const list: any[] = await res.json() const list: any[] = await res.json()
const isFirstPoll = _prevTorrentStates.size === 0 const isFirstPoll = _prevTorrentStates.size === 0
// Un seul import par torrent, même si plusieurs de ses fichiers viennent de se terminer
const toOrganize = new Map<string, any>()
for (const t of list) { for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash) const prevState = _prevTorrentStates.get(t.hash)
@@ -515,7 +527,7 @@ async function fetchActiveDownloads() {
_prevFileProgresses.set(key, f.progress) _prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) { if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(key) _recentlyOrganized.add(key)
triggerOrganize(t) toOrganize.set(t.hash, t)
} }
} }
} }
@@ -523,12 +535,14 @@ async function fetchActiveDownloads() {
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data) // Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) { if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash) _recentlyOrganized.add(t.hash)
triggerOrganize(t) toOrganize.set(t.hash, t)
} }
_prevTorrentStates.set(t.hash, t.state) _prevTorrentStates.set(t.hash, t.state)
} }
for (const t of toOrganize.values()) triggerOrganize(t)
activeTorrents.value = list.map(t => ({ activeTorrents.value = list.map(t => ({
hash : t.hash, hash : t.hash,
progress : t.progress ?? 0, progress : t.progress ?? 0,
@@ -582,6 +596,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
const result: DlItem[] = [] const result: DlItem[] = []
const covered = new Set<number>() const covered = new Set<number>()
// Une autre version déjà envoyée couvre déjà ses épisodes
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
})
// ── 1. Intégrale(s) ─────────────────────────────────────────── // ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }] ? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
@@ -589,15 +609,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const { t, i } of integrales) { for (const { t, i } of integrales) {
if (!t) continue if (!t) continue
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet }) // Épisodes apportés par CETTE intégrale
// Marquer les épisodes couverts par CETTE intégrale uniquement
const hash = t.infohash?.toLowerCase() ?? extractHash(t) const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
if (hash) if (hash)
for (const season of data.value!.seasons) for (const season of data.value!.seasons)
for (const ep of season.episodes) for (const ep of season.episodes)
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash)) if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
covered.add(ep.id) coveredByThis.add(ep.id)
const bringsSomething = coveredByThis.size === 0
|| [...coveredByThis].some(id => !covered.has(id) && !organizedEpisodeIds.value.has(id))
if (bringsSomething && !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
for (const id of coveredByThis) covered.add(id)
} }
// ── 2. Packs saison non couverts ────────────────────────────── // ── 2. Packs saison non couverts ──────────────────────────────
@@ -606,12 +634,13 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale // Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id)) const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue if (!hasUncovered) continue
let addedAny = false const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
season.torrents.forEach((t: any, i: number) => { const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` if (!alreadyHandled) {
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true } const t = season.torrents[0]
}) result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
if (addedAny) for (const ep of season.episodes) covered.add(ep.id) }
for (const ep of season.episodes) covered.add(ep.id)
} }
// ── 3. Épisodes individuels non couverts ───────────────────── // ── 3. Épisodes individuels non couverts ─────────────────────
@@ -625,17 +654,9 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
} }
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer // Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
const hasSomethingToDownload = computed(() => { // Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
if (!data.value) return false // que le garde-fou versions ne laisserait passer aucun torrent.
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
for (const season of data.value.seasons) {
if (season.torrents?.length && season.organized_state !== 'complete' &&
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
for (const ep of season.episodes)
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
}
return false
})
async function downloadAll(integraleIndex?: number) { async function downloadAll(integraleIndex?: number) {
downloadingAll.value = true downloadingAll.value = true
@@ -654,12 +675,16 @@ async function downloadSeason(season: Season, packHash?: string) {
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = [] const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
const seasonAny = season as any const seasonAny = season as any
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') { if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
seasonAny.torrents.forEach((t: any, i: number) => { const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}` const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
if (!isAlreadyQueued(t) && !isDownloaded(key)) { if (!alreadyHandled) {
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }) const chosen = packHash
} ? seasonAny.torrents.findIndex((t: any) => (t.infohash ?? extractHash(t)) === packHash)
}) : 0
const i = chosen >= 0 ? chosen : 0
const t = seasonAny.torrents[i]
torrents.push({ key: packKey(i), torrent_url: t.torrent_url, magnet: t.magnet })
}
} else { } else {
for (const ep of seasonAny.episodes ?? []) { for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant // Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
+102 -9
View File
@@ -13,6 +13,8 @@
:filters-import="filtersImport" :filters-import="filtersImport"
:sort-options="sortOptions" :sort-options="sortOptions"
:legend="legend" :legend="legend"
:selectable="auth.isAdmin"
v-model:selecting="selecting"
/> />
<!-- Grille --> <!-- Grille -->
@@ -33,14 +35,22 @@
</div> </div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }"> <div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<RouterLink <component
:is="selecting ? 'div' : RouterLink"
v-for="serie in filtered" v-for="serie in filtered"
:key="serie.id" :key="serie.id"
:to="`/series/${serie.id}`" v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5" class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }" :class="{
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
'cursor-pointer select-none': selecting,
}"
@click="selecting && toggleSelected(serie.id)"
> >
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border"> <div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
>
<img <img
v-if="serie.poster_image" v-if="serie.poster_image"
:src="serie.poster_image" :src="serie.poster_image"
@@ -52,10 +62,22 @@
<Tv :size="28" /> <Tv :size="28" />
</div> </div>
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150"> <div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span> <span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div> </div>
<div
v-if="selecting"
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
>
<Check v-if="selected.has(serie.id)" :size="13" />
</div>
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
<Rss :size="11" />
</div>
<div <div
class="absolute bottom-0 left-0 right-0 h-1" class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }" :style="{ background: stateColor(serie.download_state) }"
@@ -77,7 +99,20 @@
</span> </span>
</div> </div>
</div> </div>
</RouterLink> </component>
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
<div class="flex items-center gap-2 ml-auto">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
</div> </div>
</div> </div>
@@ -85,10 +120,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Tv } from 'lucide-vue-next' import { Tv, Check, Rss, X } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series' import { useSeriesStore, type Serie } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
@@ -117,6 +152,7 @@ const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' }, { label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' }, { label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' }, { label: 'Partiel', value: 'partial', color: '#9b59b6' },
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
] ]
const sortOptions = [ const sortOptions = [
@@ -143,6 +179,7 @@ const filtered = computed(() => {
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete') if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state)) if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial') if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
if (search.value.trim()) { if (search.value.trim()) {
const q = search.value.toLowerCase() const q = search.value.toLowerCase()
@@ -163,6 +200,62 @@ const filtered = computed(() => {
return list return list
}) })
// ── Sélection multiple ───────────────────────────────────────
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
const bulkBusy = ref(false)
watch(selecting, on => { if (!on) clearSelection() })
function toggleSelected(id: number) {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
function selectAllFiltered() { selected.value = new Set(filtered.value.map(s => s.id)) }
function clearSelection() { selected.value = new Set() }
async function bulkRename() {
bulkBusy.value = true
try {
const res = await fetch('/api/rename-all', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serie_ids: [...selected.value] }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
const errs = data.errors?.length ?? 0
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
async function bulkSync(enabled: boolean) {
bulkBusy.value = true
try {
const targets = store.series.filter((s: Serie) => selected.value.has(s.id))
const res = await fetch('/api/rss-sync/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
for (const s of targets) s.rss_synced = enabled
const n = data.changed
toast(enabled
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
bulkBusy.value = false
}
}
function stateColor(state: string): string { function stateColor(state: string): string {
return ({ return ({
complete : '#22c55e', complete : '#22c55e',
+55 -29
View File
@@ -138,16 +138,25 @@
<!-- Champs principaux --> <!-- Champs principaux -->
<template v-if="currentDefinition"> <template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key"> <div v-for="field in basicFields" :key="field.key">
<label class="settings-label"> <SettingsToggle
{{ field.label }} v-if="field.type === 'boolean'"
<span v-if="field.required" class="text-red-400 ml-0.5">*</span> :model-value="toBool(modal.config[field.key])"
</label> :label="field.label"
<input :description="fieldTooltip(field.key)"
v-model="modal.config[field.key]" @update:model-value="modal.config[field.key] = $event"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/> />
<template v-else>
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div> </div>
<!-- Paramètres avancés --> <!-- Paramètres avancés -->
@@ -167,24 +176,33 @@
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border"> <div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
<div v-for="field in advancedFields" :key="field.key"> <div v-for="field in advancedFields" :key="field.key">
<label class="settings-label flex items-center gap-1.5"> <SettingsToggle
{{ field.label }} v-if="field.type === 'boolean'"
<span :model-value="toBool(modal.config[field.key])"
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group" :label="field.label"
tabindex="-1" :description="fieldTooltip(field.key)"
> @update:model-value="modal.config[field.key] = $event"
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/> />
<template v-else>
<label class="settings-label flex items-center gap-1.5">
{{ field.label }}
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
tabindex="-1"
>
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -222,6 +240,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
const { add: toast } = useToast() const { add: toast } = useToast()
@@ -262,24 +281,30 @@ const modal = ref({
uuid : null as string | null, uuid : null as string | null,
name : '', name : '',
type : '', type : '',
config : {} as Record<string, string>, config : {} as Record<string, string | number | boolean>,
testing: false, testing: false,
saving : false, saving : false,
tested : false, tested : false,
}) })
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath'] const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = { const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.', savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.', remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.', localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
} }
function fieldTooltip(key: string): string { function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? '' return FIELD_TOOLTIPS[key] ?? ''
} }
// Tolère la forme string "true" en plus du booléen natif
function toBool(v: unknown): boolean {
return v === true || v === 'true'
}
const advancedOpen = ref(false) const advancedOpen = ref(false)
const currentDefinition = computed(() => const currentDefinition = computed(() =>
@@ -363,7 +388,8 @@ function onTypeChange() {
const def = currentDefinition.value const def = currentDefinition.value
if (def) { if (def) {
for (const field of def.fields) { for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default) if (field.default === undefined) continue
modal.value.config[field.key] = field.type === 'boolean' ? field.default : String(field.default)
} }
} }
} }
@@ -22,6 +22,14 @@
</svg> </svg>
Actualiser Actualiser
</button> </button>
<button
@click="forceMetadataRefresh"
:disabled="forcingRefresh"
class="btn-secondary"
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
>
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
</button>
<button <button
@click="purgeNfo" @click="purgeNfo"
:disabled="purgingNfo" :disabled="purgingNfo"
@@ -177,6 +185,7 @@ const { add: toast } = useToast()
const loading = ref(true) const loading = ref(true)
const renamingAll = ref(false) const renamingAll = ref(false)
const purgingNfo = ref(false) const purgingNfo = ref(false)
const forcingRefresh = ref(false)
const series = ref<any[]>([]) const series = ref<any[]>([])
const nfoSupport = ref(false) const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set()) const collapsed = ref<Set<number>>(new Set())
@@ -254,6 +263,30 @@ async function renameSingle(ep: any, serie: any) {
} }
} }
async function forceMetadataRefresh() {
forcingRefresh.value = true
try {
const res = await fetch('/api/update', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
forcingRefresh.value = false
}
}
async function purgeNfo() { async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true purgingNfo.value = true
@@ -103,6 +103,11 @@
label="Désimporter si fichier manquant" label="Désimporter si fichier manquant"
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque." description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
/> />
<SettingsToggle
v-model="form.englishDirectory"
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
/>
</div> </div>
<!-- Plex --> <!-- Plex -->
@@ -215,6 +220,7 @@ const form = ref({
autoImport : true, autoImport : true,
deleteTorrentOnMove : false, deleteTorrentOnMove : false,
autoUnimportMissing : false, autoUnimportMissing : false,
englishDirectory : false,
}) })
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
+1 -1
View File
@@ -1 +1 @@
3.20.2 3.22.0