Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a32b1c64fe | ||
|
|
ffc925dbdb | ||
|
|
f4f213508d | ||
|
|
7f06dd654a | ||
|
|
7479ddf8eb | ||
|
|
0768964041 | ||
|
|
9b66c57a6a | ||
|
|
3eb64b175d | ||
|
|
25a8adc91d | ||
|
|
d533751ffb | ||
|
|
6f1e0dc844 | ||
|
|
894a7ed2b1 | ||
|
|
c87b7a4c7f | ||
|
|
a000e349c9 | ||
|
|
f23e3c04ec | ||
|
|
63146fdc27 | ||
|
|
7795598346 | ||
|
|
04252db0c4 | ||
|
|
aeb2e938ef | ||
|
|
3799d33c62 | ||
|
|
98b317d493 | ||
|
|
4292f96645 | ||
|
|
182c7cc807 | ||
|
|
53be057425 | ||
|
|
ac70dba927 | ||
|
|
f79cc244ef | ||
|
|
14137e1b1b | ||
|
|
43ee4597a9 | ||
|
|
f311c8e2f3 | ||
|
|
48933b4078 | ||
|
|
45288a130f | ||
|
|
73256eb15d | ||
|
|
6855c04fab | ||
|
|
f6f735ad88 | ||
|
|
a7250a5f80 | ||
|
|
d326e08834 | ||
|
|
0e2d513cf7 | ||
|
|
5cf530ba78 | ||
|
|
3bc2e08ca5 |
No files matched your search
+2
-1
@@ -5,4 +5,5 @@ node_modules
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.example
|
||||
.test-sandbox
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -27,3 +27,8 @@ binaries
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Instance de test locale
|
||||
.test-sandbox
|
||||
scripts/seed-test-sandbox.mjs
|
||||
docker-compose.test.yml
|
||||
@@ -1,5 +1,59 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
|
||||
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||
|
||||
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
|
||||
|
||||
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
|
||||
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
|
||||
|
||||
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fankarr</title>
|
||||
<title>Fankarr</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 231 KiB |
+30
-25
@@ -14,10 +14,11 @@ import utorrentDriver from './torrent-clients/utorrent.js'
|
||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||
import realDebridDriver from './torrent-clients/real-debrid.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 { DATA_DIR, BASE_DIR } from './config.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 { readRequests, completeRequest } from './requests.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
@@ -91,6 +92,9 @@ const ADMIN_PREFIXES = [
|
||||
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
||||
'/downloads', '/download', '/organize', '/import',
|
||||
'/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) => {
|
||||
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}`)
|
||||
}
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
|
||||
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
||||
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
|
||||
}
|
||||
const raw = readOrganized()
|
||||
const isOldFormat = Object.values(raw).some((entries: any) =>
|
||||
Object.values(entries).some(v => typeof v === 'string')
|
||||
)
|
||||
if (isOldFormat) {
|
||||
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
|
||||
writeOrganized({})
|
||||
}
|
||||
} 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
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH)) {
|
||||
const raw: Record<string, Record<string, any>> = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
const raw = readOrganized()
|
||||
let patched = 0
|
||||
for (const episodes of Object.values(raw)) {
|
||||
for (const entry of Object.values(episodes)) {
|
||||
if (!entry.dest_dir && entry.dest_path) {
|
||||
entry.dest_dir = path.dirname(entry.dest_path)
|
||||
patched++
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(raw, null, 2))
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
|
||||
}
|
||||
}
|
||||
if (patched > 0) {
|
||||
writeOrganized(raw)
|
||||
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
|
||||
@@ -177,6 +178,7 @@ server.listen(PORT, async () => {
|
||||
loadEnrichedSeriesData()
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
dedupeOrganizedEpisodes(ORGANIZED_PATH)
|
||||
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
})
|
||||
.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)
|
||||
if (updated > 0)
|
||||
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)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
|
||||
|
||||
+24
-15
@@ -2,8 +2,9 @@ import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
const GITHUB_BASE =
|
||||
process.env.GITHUB_BASE?.trim() ||
|
||||
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
@@ -25,34 +26,42 @@ export function cacheClear(): void { _cache.clear() }
|
||||
|
||||
export function cacheSize(): number { return _cache.size }
|
||||
|
||||
export async function githubGet(urlPath: string): Promise<any> {
|
||||
const cached = cacheGet(urlPath)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
/**
|
||||
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
|
||||
* (~5 min).
|
||||
*/
|
||||
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}`)
|
||||
const data = await res.json()
|
||||
cacheSet(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function readAvailable(): Promise<number[]> {
|
||||
try { return await githubGet('available.json') as number[] }
|
||||
export async function readAvailable(force = false): Promise<number[]> {
|
||||
try { return await githubGet('available.json', force) as number[] }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
export async function readInfohashMap(): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json') as Record<string, string> }
|
||||
export async function readInfohashMap(force = false): Promise<Record<string, string>> {
|
||||
try { return await githubGet('infohash_map.json', force) as Record<string, string> }
|
||||
catch { return {} }
|
||||
}
|
||||
|
||||
export async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`) }
|
||||
export async function readSerieData(serieId: number, force = false): Promise<any | null> {
|
||||
try { return await githubGet(`series/${serieId}.json`, force) }
|
||||
catch { return null }
|
||||
}
|
||||
|
||||
export async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
export async function loadEnrichedSeriesData(force = false): Promise<any[]> {
|
||||
const ids = await readAvailable(force)
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id, force)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,18 @@ export function setSync(serieId: number, serieName: string, enabled: boolean): S
|
||||
return map
|
||||
}
|
||||
|
||||
// Une série déjà surveillée garde sa date d'activation
|
||||
export function setSyncBulk(series: { id: number; name: string }[], enabled: boolean): { changed: number; map: SyncedMap } {
|
||||
const map = loadSynced()
|
||||
let changed = 0
|
||||
for (const { id, name } of series) {
|
||||
if (enabled && !map[id]) { map[id] = { serieId: id, serieName: name, addedAt: new Date().toISOString() }; changed++ }
|
||||
if (!enabled && map[id]) { delete map[id]; changed++ }
|
||||
}
|
||||
if (changed > 0) saveSynced(map)
|
||||
return { changed, map }
|
||||
}
|
||||
|
||||
export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
+87
-24
@@ -10,8 +10,7 @@ import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { getGitlabTitle } from './gitlab-map.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
import { readOrganized } from './lib/organized-store.js'
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||
@@ -35,34 +34,28 @@ export interface OrgEntry {
|
||||
type Organized = Record<string, Record<string, OrgEntry>>
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
|
||||
try {
|
||||
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'))
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][String(episodeId)] = entry
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, episodeId: number): boolean {
|
||||
return !!loadOrganized()[hash]?.[String(episodeId)]
|
||||
return !!readOrganized()[hash]?.[String(episodeId)]
|
||||
}
|
||||
|
||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||
|
||||
/**
|
||||
* Recherche par infohash (méthode principale).
|
||||
*/
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
const h = hash.toLowerCase()
|
||||
for (const sd of seriesData) {
|
||||
@@ -101,6 +94,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
|
||||
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
|
||||
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
|
||||
*/
|
||||
function findTorrentByName(
|
||||
name : string,
|
||||
seriesData: any[],
|
||||
): { torrent: any; serieData: any; infohash: string } | null {
|
||||
const n = name.toLowerCase().trim()
|
||||
if (!n) return null
|
||||
|
||||
for (const sd of seriesData) {
|
||||
// Pack intégral (torrents au niveau série)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
// Pack saison
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
|
||||
// Torrent épisode unique
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (!t.infohash) continue
|
||||
if (
|
||||
t.torrent_name?.toLowerCase().trim() === n ||
|
||||
t.title?.toLowerCase().trim() === n
|
||||
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
@@ -251,7 +291,8 @@ function swapExtension(filename: string, ext: string): 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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,13 +539,35 @@ parentPort?.on('message', async (msg: any) => {
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
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) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
// Résolution principale : par infohash
|
||||
let found = findTorrentByHash(t.hash, seriesData)
|
||||
let effectiveHash = t.hash
|
||||
|
||||
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
|
||||
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
|
||||
// qui correspond au champ torrent_name dans les données série.
|
||||
if (!found && t.name) {
|
||||
const byName = findTorrentByName(t.name, seriesData)
|
||||
if (byName) {
|
||||
found = byName
|
||||
effectiveHash = byName.infohash
|
||||
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||
continue
|
||||
@@ -512,8 +575,8 @@ parentPort?.on('message', async (msg: any) => {
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[t.hash] ?? {}
|
||||
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
|
||||
const orgHash = organized[effectiveHash] ?? {}
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||
: Object.keys(orgHash).length > 0
|
||||
@@ -546,8 +609,8 @@ parentPort?.on('message', async (msg: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
|
||||
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
|
||||
} catch (err) {
|
||||
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
+178
-96
@@ -8,10 +8,8 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js'
|
||||
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
@@ -30,18 +28,54 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
interface OrgEntry {
|
||||
at : string
|
||||
season : number
|
||||
episode : number
|
||||
episode_id : number
|
||||
src_filename : string
|
||||
dest_filename: string
|
||||
dest_path : string
|
||||
dest_dir? : string // absent dans les anciennes entrées — backfillé au démarrage
|
||||
type WorkerResult = { hash: string; name: string; serieId?: number | null; total: number; done: number; skipped: number; errors: { file: string; error: string }[] }
|
||||
|
||||
// ── File d'attente des workers ────────────────────────────────
|
||||
let _queue: Promise<unknown> = Promise.resolve()
|
||||
let _queued = 0
|
||||
const _pendingManual = new Map<string, Promise<OrganizeResult>>()
|
||||
|
||||
function enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||
_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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -65,10 +99,8 @@ export async function scanMediaPath(
|
||||
|
||||
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, {
|
||||
hash : string
|
||||
hashes : string[]
|
||||
srcFilename : string
|
||||
episodeId : number
|
||||
season : number
|
||||
@@ -84,8 +116,13 @@ export async function scanMediaPath(
|
||||
const idx = (filename: string, hash: string, destFilename?: string) => {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
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, {
|
||||
hash,
|
||||
hashes : [hash],
|
||||
srcFilename : filename,
|
||||
episodeId : ep.id,
|
||||
season : season.season_number,
|
||||
@@ -123,11 +160,12 @@ export async function scanMediaPath(
|
||||
}
|
||||
}
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return result
|
||||
}
|
||||
|
||||
// Track les paires hash:episodeId présentes
|
||||
const presentFiles = new Set<string>()
|
||||
@@ -167,7 +205,8 @@ export async function scanMediaPath(
|
||||
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}`)
|
||||
|
||||
if (organized[hash]?.[String(episodeId)]) continue
|
||||
@@ -190,11 +229,17 @@ export async function scanMediaPath(
|
||||
|
||||
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
|
||||
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
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
if (mediaEmpty) break
|
||||
for (const episodeId of Object.keys(episodes)) {
|
||||
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
|
||||
delete organized[hash][episodeId]
|
||||
@@ -207,7 +252,7 @@ export async function scanMediaPath(
|
||||
// Désimport automatique des fichiers manquants sur le disque
|
||||
const { autoUnimportMissing } = readSettings() as any
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing) {
|
||||
if (autoUnimportMissing && !mediaEmpty) {
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
|
||||
const destPath = entry?.dest_path
|
||||
@@ -222,7 +267,7 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
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` : ''}`)
|
||||
@@ -243,8 +288,8 @@ export async function autoOrganizeAll(
|
||||
return
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
logger.debug('organize', 'Worker déjà en cours — skip')
|
||||
if (_queued > 0) {
|
||||
logger.debug('organize', 'Import déjà en cours — skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -268,11 +313,12 @@ export async function autoOrganizeAll(
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(ORGANIZED_PATH))
|
||||
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch {}
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) {
|
||||
logger.error('organize', `Import automatique annulé : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
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)`)
|
||||
}
|
||||
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
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') {
|
||||
try {
|
||||
await enqueue(() => runWorker(torrents, seriesData, msg => {
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
@@ -316,24 +357,11 @@ export async function autoOrganizeAll(
|
||||
errors : msg.errors.length,
|
||||
errorFiles: msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}))
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
} catch (err) {
|
||||
logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration des IDs épisodes ────────────────────────────────
|
||||
@@ -368,11 +396,12 @@ export async function migrateOrganizedEpisodeIds(
|
||||
}
|
||||
|
||||
// 2. Charger organized.json
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { updated: 0, orphaned: 0 } }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return { updated: 0, orphaned: 0 }
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
let orphaned = 0
|
||||
@@ -422,7 +451,7 @@ export async function migrateOrganizedEpisodeIds(
|
||||
}
|
||||
|
||||
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` : ''}`)
|
||||
} else {
|
||||
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 }
|
||||
}
|
||||
|
||||
// ── 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 ────
|
||||
|
||||
export async function syncFilenameChanges(
|
||||
@@ -438,11 +519,12 @@ export async function syncFilenameChanges(
|
||||
organizedPath: string,
|
||||
): Promise<{ renamed: number; errors: number }> {
|
||||
const { nfoSupport } = readSettings()
|
||||
let organized: Organized = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch { return { renamed: 0, errors: 0 } }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized(organizedPath) }
|
||||
catch (err) {
|
||||
logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
|
||||
return { renamed: 0, errors: 0 }
|
||||
}
|
||||
|
||||
let renamed = 0
|
||||
let errors = 0
|
||||
@@ -506,7 +588,7 @@ export async function syncFilenameChanges(
|
||||
}
|
||||
|
||||
if (changed)
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
writeOrganized(organized, organizedPath)
|
||||
|
||||
if (renamed > 0 || errors > 0)
|
||||
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
|
||||
@@ -520,19 +602,27 @@ export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
seriesData: any[],
|
||||
files? : { name?: string; progress: number; priority?: number }[],
|
||||
): 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) => {
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
// files : progression par fichier récupérée depuis le client torrent.
|
||||
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
|
||||
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
|
||||
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) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
const job = enqueue(async () => {
|
||||
_pendingManual.delete(key)
|
||||
try {
|
||||
await runWorker([fakeTorrent], seriesData, msg => {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
@@ -545,21 +635,13 @@ export async function organizeTorrent(
|
||||
} else {
|
||||
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
}
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(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 })
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
|
||||
throw err
|
||||
}
|
||||
return result
|
||||
})
|
||||
_pendingManual.set(key, job)
|
||||
return job
|
||||
}
|
||||
+81
-55
@@ -1,9 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.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 { dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readRequests, deleteRequest } from '../requests.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js'
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
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
|
||||
}
|
||||
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))
|
||||
if (!sd) {
|
||||
// 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)
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
for (const [epId, entry] of Object.entries(eps)) {
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) {
|
||||
delete (organized[h] as any)[epId]
|
||||
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
|
||||
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 })
|
||||
if (file_path !== destPath) {
|
||||
@@ -96,15 +92,17 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
fs.unlinkSync(destPath)
|
||||
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
|
||||
// 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)) {
|
||||
if (entry?.dest_path === destPath) {
|
||||
delete (organized[h] as any)[epId]
|
||||
if (entry?.dest_path === destPath)
|
||||
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)) {
|
||||
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`)
|
||||
}
|
||||
const torrentHash = String(hash || '').toLowerCase() || 'manual'
|
||||
if (!organized[torrentHash]) organized[torrentHash] = {}
|
||||
organized[torrentHash][String(episode_id)] = {
|
||||
const entry = {
|
||||
at: new Date().toISOString(), season: season.season_number,
|
||||
episode: ep.episode_number, episode_id: ep.id,
|
||||
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
|
||||
}
|
||||
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
|
||||
done.push(episode_id)
|
||||
} catch (err) {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
const gitlabTitle = getGitlabTitle(serieTitle)
|
||||
@@ -175,7 +179,7 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sd = await readSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
|
||||
const organized = loadOrganizedJson()
|
||||
const organized = readOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
const result: Record<string, any> = {}
|
||||
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
|
||||
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serie_id))
|
||||
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
|
||||
for (const season of sd.seasons ?? []) {
|
||||
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}`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
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}"`)
|
||||
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
|
||||
} catch (err) {
|
||||
@@ -252,9 +258,11 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
const serieId = String(req.params.serieId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
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>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
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) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
if (hash === 'manual') continue
|
||||
@@ -312,9 +320,11 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
|
||||
const serieId = String(req.params.serieId)
|
||||
const seasonId = Number(req.params.seasonId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
const sd = await readSerieData(Number(serieId))
|
||||
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)
|
||||
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
|
||||
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) }
|
||||
}
|
||||
saveOrganizedJson(organized)
|
||||
writeOrganized(organized)
|
||||
if (deleteFile) {
|
||||
for (const hash of emptyHashes) {
|
||||
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) => {
|
||||
const episodeId = String(req.params.episodeId)
|
||||
const deleteFile = req.query.deleteFile === 'true'
|
||||
const organized = loadOrganizedJson()
|
||||
let foundHash: string | null = null
|
||||
let foundEntry: any = null
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
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)) {
|
||||
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 {
|
||||
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
|
||||
fs.unlinkSync(foundEntry.dest_path)
|
||||
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
|
||||
const deletedPaths = new Set<string>()
|
||||
if (deleteFile) {
|
||||
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 torrentEmpty = Object.keys(organized[foundHash]).length === 0
|
||||
if (torrentEmpty) delete organized[foundHash]
|
||||
saveOrganizedJson(organized)
|
||||
// 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}`))
|
||||
const emptyHashes: string[] = []
|
||||
for (const { hash } of found) {
|
||||
delete organized[hash][episodeId]
|
||||
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
|
||||
}
|
||||
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 })
|
||||
} catch (err) {
|
||||
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) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const organized = loadOrganizedJson()
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result: any[] = []
|
||||
for (const sd of seriesData) {
|
||||
@@ -421,15 +444,18 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
})
|
||||
|
||||
// ── Rename en masse ────────────────────────────────────────────
|
||||
router.post('/rename-all', requireAuth, async (req, res) => {
|
||||
const { serie_id } = req.body
|
||||
router.post('/rename-all', requireAdmin, async (req, res) => {
|
||||
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 organized = loadOrganizedJson()
|
||||
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 errors: { episode_id: number; error: string }[] = []
|
||||
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 ep of season.episodes ?? []) {
|
||||
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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
|
||||
@@ -36,7 +37,13 @@ router.post('/organize', requireAuth, async (req, res) => {
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
|
||||
// Récupère la progression par fichier depuis le client torrent.
|
||||
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
|
||||
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
|
||||
const files = await dispatchGetFiles(hash).catch(() => [])
|
||||
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { requireAuth, requireAdmin } from '../auth.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()
|
||||
|
||||
@@ -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 */
|
||||
router.get('/rss-sync/:id', requireAuth, (req, res) => {
|
||||
const serieId = Number(req.params.id)
|
||||
|
||||
+10
-4
@@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { dispatchList } from '../torrent-clients/index.js'
|
||||
import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cache.js'
|
||||
import { loadSynced } from '../lib/rss-sync.js'
|
||||
import {
|
||||
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
@@ -22,14 +23,19 @@ router.get('/series', requireAuth, async (_req, res) => {
|
||||
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 {}
|
||||
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 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) }))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const hasTorrents = serieData ? extractTorrentsFromSerieData(serieData).length > 0 : false
|
||||
return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) }
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
|
||||
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) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
|
||||
@@ -10,7 +10,7 @@ router.get('/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> = {}
|
||||
if (mediaPath !== undefined) patch.mediaPath = mediaPath
|
||||
if (completePath !== undefined) patch.completePath = completePath
|
||||
@@ -22,6 +22,7 @@ router.post('/settings', requireAuth, (req, res) => {
|
||||
if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
|
||||
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
|
||||
if (requestAutoDownloadUsers !== undefined) patch.requestAutoDownloadUsers = requestAutoDownloadUsers
|
||||
if (englishDirectory !== undefined) patch.englishDirectory = englishDirectory
|
||||
const updated = writeSettings(patch)
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
|
||||
+21
-5
@@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.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 { cacheSize } from '../lib/github-cache.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 }) }
|
||||
})
|
||||
|
||||
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 {
|
||||
cacheClear()
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
logger.info('api', force
|
||||
? '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')
|
||||
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 })
|
||||
;(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Settings {
|
||||
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
|
||||
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
|
||||
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 = {
|
||||
@@ -30,6 +31,7 @@ const defaults: Settings = {
|
||||
requestAutoDownloadUsers: [],
|
||||
jellyfinUrl : '',
|
||||
jellyfinAdminToken : '',
|
||||
englishDirectory : false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* 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'
|
||||
|
||||
// ─── Session cache ─────────────────────────────────────────────────────────────
|
||||
@@ -18,13 +19,13 @@ import { logger } from '../logger.js'
|
||||
interface SessionEntry { cookie: string; expires: number }
|
||||
const _sessions = new Map<string, SessionEntry>()
|
||||
|
||||
function sessionKey(config: Record<string, string | number>): string {
|
||||
function sessionKey(config: ClientConfig): string {
|
||||
return `${config.url}::${config.password}`
|
||||
}
|
||||
|
||||
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
|
||||
|
||||
function delugeUrl(config: Record<string, string | number>): string {
|
||||
function delugeUrl(config: ClientConfig): string {
|
||||
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
|
||||
}
|
||||
|
||||
@@ -34,12 +35,13 @@ function parseCookie(raw: string): string {
|
||||
}
|
||||
|
||||
async function delugeRPC(
|
||||
config : ClientConfig,
|
||||
url : string,
|
||||
method : string,
|
||||
params : unknown[],
|
||||
cookie : string,
|
||||
): Promise<{ result: any; newCookie: string | null }> {
|
||||
const res = await fetch(url, {
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -68,9 +70,9 @@ async function delugeRPC(
|
||||
|
||||
// ─── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function delugeLogin(config: Record<string, string | number>): Promise<string> {
|
||||
async function delugeLogin(config: ClientConfig): Promise<string> {
|
||||
const url = delugeUrl(config)
|
||||
const res = await fetch(url, {
|
||||
const res = await clientFetch(config, url, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
async function delugeCall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : unknown[] = [],
|
||||
): Promise<any> {
|
||||
@@ -110,7 +112,7 @@ async function delugeCall(
|
||||
}
|
||||
|
||||
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
|
||||
if (newCookie) {
|
||||
entry.cookie = newCookie
|
||||
@@ -125,7 +127,7 @@ async function delugeCall(
|
||||
const cookie = await delugeLogin(config)
|
||||
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||
_sessions.set(key, fresh)
|
||||
const { result } = await delugeRPC(url, method, params, cookie)
|
||||
const { result } = await delugeRPC(config, url, method, params, cookie)
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
@@ -178,7 +180,7 @@ function flattenDelugeFiles(
|
||||
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
|
||||
|
||||
async function applyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -211,6 +213,7 @@ const delugeDriver: TorrentClientDriver = {
|
||||
{ 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: '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 },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
+131
-15
@@ -7,31 +7,38 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Agent, fetch as undiciFetch } from 'undici'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── 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 {
|
||||
key : string
|
||||
label : string
|
||||
type : 'text' | 'password' | 'number' | 'url'
|
||||
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
|
||||
placeholder?: string
|
||||
required : boolean
|
||||
default? : string | number
|
||||
default? : string | number | boolean
|
||||
}
|
||||
|
||||
export interface TorrentClientDefinition {
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
/** Si true, dispatchList filtre les résultats pour n'inclure que les
|
||||
* torrents ajoutés via FanKarr (clients sans catégories natives). */
|
||||
filterByManagedHashes?: boolean
|
||||
}
|
||||
|
||||
export interface SavedClient {
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: Record<string, string | number>
|
||||
config: ClientConfig
|
||||
}
|
||||
|
||||
export interface TorrentFileProgress {
|
||||
@@ -67,12 +74,50 @@ export interface DownloadOptions {
|
||||
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
|
||||
test : (config: ClientConfig) => Promise<{ ok: boolean; message: string }>
|
||||
healthcheck : (config: ClientConfig) => Promise<{ online: boolean; version?: string }>
|
||||
add : (config: ClientConfig, url: string, options?: DownloadOptions) => Promise<void>
|
||||
list : (config: ClientConfig, category?: string) => Promise<TorrentInfo[]>
|
||||
remove : (config: ClientConfig, hash: string, deleteFiles?: boolean) => Promise<void>
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -94,7 +139,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
// ─── Managed tasks ─────────────────────────────────────────────────────────────
|
||||
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
|
||||
// Utilisé par dispatchList pour filtrer les résultats des clients sans
|
||||
// catégories natives (ex: Synology Download Station).
|
||||
// Structure : { clientUuid: [infohash, ...] }
|
||||
|
||||
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
|
||||
|
||||
function loadManagedMap(): Record<string, string[]> {
|
||||
try {
|
||||
if (!fs.existsSync(MANAGED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function saveManagedMap(data: Record<string, string[]>) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
|
||||
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
|
||||
function getManagedSet(clientUuid: string): Set<string> | null {
|
||||
const list = loadManagedMap()[clientUuid]
|
||||
if (!list || list.length === 0) return null
|
||||
return new Set(list)
|
||||
}
|
||||
|
||||
/** Enregistre un infohash comme géré pour un client (idempotent). */
|
||||
export function trackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) data[clientUuid] = []
|
||||
const h = infohash.toLowerCase()
|
||||
if (!data[clientUuid].includes(h)) {
|
||||
data[clientUuid].push(h)
|
||||
saveManagedMap(data)
|
||||
}
|
||||
}
|
||||
|
||||
/** Retire un infohash de la liste gérée pour un client. */
|
||||
export function untrackManagedHash(clientUuid: string, infohash: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
|
||||
if (data[clientUuid].length === 0) delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
|
||||
function clearManagedHashes(clientUuid: string) {
|
||||
const data = loadManagedMap()
|
||||
if (!data[clientUuid]) return
|
||||
delete data[clientUuid]
|
||||
saveManagedMap(data)
|
||||
}
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
@@ -114,7 +216,7 @@ export function listClients(): SavedClient[] {
|
||||
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 client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
@@ -129,6 +231,7 @@ export function removeClient(uuid: string): boolean {
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
clearManagedHashes(uuid)
|
||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
@@ -137,7 +240,7 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
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 index = clients.findIndex(c => c.uuid === uuid)
|
||||
if (index === -1) return null
|
||||
@@ -243,6 +346,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url, options)
|
||||
if (options?.infohash) {
|
||||
trackManagedHash(client.uuid, options.infohash)
|
||||
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
|
||||
}
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
@@ -266,6 +373,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
|
||||
try {
|
||||
await driver.remove(client.config, hash, deleteFiles)
|
||||
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||
untrackManagedHash(client.uuid, hash)
|
||||
found = true
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -328,6 +436,14 @@ export async function dispatchList(
|
||||
}
|
||||
}
|
||||
|
||||
// Filtre par hashes gérés pour les clients sans catégories natives.
|
||||
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
|
||||
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
|
||||
if (driver.definition.filterByManagedHashes) {
|
||||
const managed = getManagedSet(client.uuid)
|
||||
if (managed && !managed.has(hash)) continue
|
||||
}
|
||||
|
||||
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 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'
|
||||
|
||||
// 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
|
||||
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
|
||||
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
|
||||
if (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 }
|
||||
}
|
||||
|
||||
async function qbLogin(config: Record<string, string | number>): Promise<string> {
|
||||
async function qbLogin(config: ClientConfig): Promise<string> {
|
||||
const form = new URLSearchParams()
|
||||
form.append('username', String(config.username ?? ''))
|
||||
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',
|
||||
body : form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -55,9 +56,9 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
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).
|
||||
*/
|
||||
async function qbApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -82,7 +83,7 @@ async function qbApplyFilePriority(
|
||||
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
|
||||
try {
|
||||
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,
|
||||
})
|
||||
if (!res.ok) continue
|
||||
@@ -105,13 +106,13 @@ async function qbApplyFilePriority(
|
||||
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
|
||||
if (unwanted.length > 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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
|
||||
headers,
|
||||
@@ -119,7 +120,7 @@ async function qbApplyFilePriority(
|
||||
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
|
||||
|
||||
// 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) {
|
||||
const verifiedFiles: any[] = await verif.json()
|
||||
const target = verifiedFiles[fileIndex]
|
||||
@@ -152,13 +153,14 @@ const QB: TorrentClientDriver = {
|
||||
{ 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: '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) {
|
||||
try {
|
||||
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}`)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
@@ -172,7 +174,7 @@ const QB: TorrentClientDriver = {
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
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,
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
@@ -190,7 +192,7 @@ const QB: TorrentClientDriver = {
|
||||
const authH = await qbAuth(config)
|
||||
const params = new URLSearchParams()
|
||||
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,
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
@@ -200,7 +202,7 @@ const QB: TorrentClientDriver = {
|
||||
const fileMap = new Map<string, any[]>()
|
||||
await Promise.all(data.map(async (t: any) => {
|
||||
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,
|
||||
})
|
||||
if (r.ok) fileMap.set(t.hash, await r.json())
|
||||
@@ -237,7 +239,7 @@ const QB: TorrentClientDriver = {
|
||||
|
||||
async getFiles(config, hash) {
|
||||
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,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
@@ -275,7 +277,7 @@ const QB: TorrentClientDriver = {
|
||||
let knownHashes: Set<string> = new Set()
|
||||
if (!hash) {
|
||||
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()))
|
||||
} catch {}
|
||||
}
|
||||
@@ -292,7 +294,7 @@ const QB: TorrentClientDriver = {
|
||||
params.set('urls', url)
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
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,
|
||||
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.savePath) form.append('savepath', String(config.savePath))
|
||||
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()
|
||||
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
|
||||
@@ -325,7 +327,7 @@ const QB: TorrentClientDriver = {
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
try {
|
||||
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
|
||||
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
|
||||
if (!newT) continue
|
||||
@@ -347,7 +349,7 @@ const QB: TorrentClientDriver = {
|
||||
if (config.category) params.set('category', String(config.category))
|
||||
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,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
@@ -359,7 +361,7 @@ const QB: TorrentClientDriver = {
|
||||
async remove(config, hash, deleteFiles = false) {
|
||||
const authH = await qbAuth(config)
|
||||
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',
|
||||
body : params,
|
||||
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
* (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'
|
||||
|
||||
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
|
||||
|
||||
function rdHeaders(config: Record<string, string | number>) {
|
||||
function rdHeaders(config: ClientConfig) {
|
||||
return {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -26,7 +26,7 @@ function rdHeaders(config: Record<string, string | number>) {
|
||||
}
|
||||
|
||||
async function rdFetch(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : 'GET' | 'POST' | 'DELETE',
|
||||
path : string,
|
||||
body ?: URLSearchParams,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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'
|
||||
|
||||
// ─── XML-RPC helpers ──────────────────────────────────────────
|
||||
@@ -55,7 +56,7 @@ function parseXmlValue(node: Element): any {
|
||||
}
|
||||
|
||||
async function rpcCall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
method : string,
|
||||
params : any[] = [],
|
||||
): Promise<any> {
|
||||
@@ -69,7 +70,7 @@ async function rpcCall(
|
||||
const rpcPath = String(config.rpcPath || '/RPC2')
|
||||
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}`)
|
||||
|
||||
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
|
||||
async function d_multicall(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
view : string,
|
||||
methods: string[],
|
||||
): Promise<any[][]> {
|
||||
@@ -118,7 +119,7 @@ async function d_multicall(
|
||||
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 {
|
||||
const rows = await d_multicall(config, 'main', ['d.hash'])
|
||||
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.
|
||||
*/
|
||||
async function rtApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -192,6 +193,7 @@ const RT: TorrentClientDriver = {
|
||||
{ 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: '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 },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
import fs from 'fs'
|
||||
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 { DATA_DIR } from '../config.js'
|
||||
|
||||
@@ -83,7 +84,7 @@ function mapState(status: string): TorrentInfo['state'] {
|
||||
|
||||
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
||||
async function dsLogin(config: ClientConfig): Promise<string> {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.API.Auth',
|
||||
version: '3',
|
||||
@@ -93,7 +94,7 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
|
||||
session: 'DownloadStation',
|
||||
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}`)
|
||||
const data = await res.json()
|
||||
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(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
api : string,
|
||||
method : string,
|
||||
version: string,
|
||||
@@ -109,7 +110,7 @@ async function dsRequest(
|
||||
sid : string,
|
||||
): Promise<any> {
|
||||
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}`)
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
|
||||
@@ -120,9 +121,10 @@ async function dsRequest(
|
||||
|
||||
const DS: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'synology-ds',
|
||||
label : 'Synology Download Station',
|
||||
fields: [
|
||||
id : 'synology-ds',
|
||||
label : 'Synology Download Station',
|
||||
filterByManagedHashes: true,
|
||||
fields : [
|
||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
@@ -130,6 +132,7 @@ const DS: TorrentClientDriver = {
|
||||
{ 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: '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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -154,7 +157,7 @@ const DS: TorrentClientDriver = {
|
||||
method : 'getinfo',
|
||||
_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 }
|
||||
const data = await res.json()
|
||||
if (!data.success) return { online: false }
|
||||
@@ -172,7 +175,17 @@ const DS: TorrentClientDriver = {
|
||||
|
||||
const tasks: any[] = data?.tasks ?? []
|
||||
|
||||
return tasks.map(t => {
|
||||
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
|
||||
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
|
||||
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
const filtered = normalizedSavePath
|
||||
? tasks.filter(t => {
|
||||
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
|
||||
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
|
||||
})
|
||||
: tasks
|
||||
|
||||
return filtered.map(t => {
|
||||
const transfer = t.additional?.transfer ?? {}
|
||||
const detail = t.additional?.detail ?? {}
|
||||
const fileList: any[] = t.additional?.file ?? []
|
||||
@@ -235,37 +248,51 @@ const DS: TorrentClientDriver = {
|
||||
|
||||
const sid = await dsLogin(config)
|
||||
|
||||
const makeBody = (withDestination: boolean) => {
|
||||
const body = new URLSearchParams({
|
||||
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
|
||||
const beforeIds = new Set<string>()
|
||||
try {
|
||||
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
|
||||
} catch {}
|
||||
|
||||
const doAdd = async (withDestination: boolean) => {
|
||||
const params = new URLSearchParams({
|
||||
api : 'SYNO.DownloadStation.Task',
|
||||
version: '1',
|
||||
method : 'create',
|
||||
_sid : sid,
|
||||
uri : url,
|
||||
})
|
||||
if (withDestination && config.savePath) body.append('destination', String(config.savePath))
|
||||
return body
|
||||
}
|
||||
|
||||
const doRequest = async (body: URLSearchParams) => {
|
||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body : body.toString(),
|
||||
})
|
||||
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
|
||||
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
let data = await doRequest(makeBody(true))
|
||||
let data = await doAdd(true)
|
||||
|
||||
// Code 403 = destination inexistante sur le NAS → réessai sans destination
|
||||
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
|
||||
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
||||
data = await doRequest(makeBody(false))
|
||||
data = await doAdd(false)
|
||||
}
|
||||
|
||||
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
||||
|
||||
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
|
||||
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
|
||||
if (options?.infohash) {
|
||||
try {
|
||||
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
|
||||
if (newTask) {
|
||||
const synoId = String(newTask.id).toLowerCase()
|
||||
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
|
||||
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||
},
|
||||
|
||||
@@ -330,7 +357,7 @@ const DS: TorrentClientDriver = {
|
||||
force_complete: 'false',
|
||||
_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}`)
|
||||
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
|
||||
},
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* 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'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
@@ -17,7 +18,7 @@ function mapState(status: number): TorrentInfo['state'] {
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: Record<string, string | number>,
|
||||
config: ClientConfig,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
@@ -31,7 +32,7 @@ async function trRequest(
|
||||
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',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
@@ -52,7 +53,7 @@ async function trRequest(
|
||||
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 torrents = data.arguments?.torrents ?? []
|
||||
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.
|
||||
*/
|
||||
async function trApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -102,6 +103,7 @@ const TR: TorrentClientDriver = {
|
||||
{ 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: '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 },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* 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'
|
||||
|
||||
// uTorrent status flags (bitmask) :
|
||||
@@ -21,7 +22,7 @@ function mapState(status: number): TorrentInfo['state'] {
|
||||
// uTorrent nécessite un token CSRF + cookie à chaque session
|
||||
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 {
|
||||
const session = await utGetSession(config)
|
||||
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).
|
||||
*/
|
||||
async function utApplyFilePriority(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
hash : string,
|
||||
fileIndex: number,
|
||||
): Promise<void> {
|
||||
@@ -54,13 +55,13 @@ async function utApplyFilePriority(
|
||||
// Passer tous les fichiers à priorité 0 (skip)
|
||||
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))
|
||||
await fetch(`${config.url}/gui/?${skipQs}`, {
|
||||
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
// Activer le fichier cible (priorité 2 = normal)
|
||||
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 },
|
||||
})
|
||||
|
||||
@@ -71,13 +72,13 @@ async function utApplyFilePriority(
|
||||
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 headers : Record<string, string> = {
|
||||
'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`)
|
||||
|
||||
const text = await res.text()
|
||||
@@ -90,14 +91,14 @@ async function utGetSession(config: Record<string, string | number>): Promise<UT
|
||||
}
|
||||
|
||||
async function utRequest(
|
||||
config : Record<string, string | number>,
|
||||
config : ClientConfig,
|
||||
params : Record<string, string>,
|
||||
session: UTSession,
|
||||
): Promise<any> {
|
||||
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
|
||||
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: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Cookie' : session.cookie,
|
||||
@@ -142,6 +143,7 @@ const UT: TorrentClientDriver = {
|
||||
{ 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: '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 })
|
||||
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',
|
||||
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
|
||||
})
|
||||
|
||||
@@ -606,33 +606,49 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
|
||||
*/
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
chapters: string | null
|
||||
season: number | null
|
||||
chapters: string |null
|
||||
cleanPlot: string
|
||||
} {
|
||||
const text = plot ?? ''
|
||||
|
||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||||
const epMatch = text.match(
|
||||
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const seasonMatch = text.match(/saison\s+(\d+)/i)
|
||||
|
||||
const season = epMatch?.[3] ?? seasonMatch?.[1] ?? null
|
||||
|
||||
const chapMatch = text.match(
|
||||
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
|
||||
)
|
||||
|
||||
const episodes = epMatch
|
||||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||||
: null
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
? `${season ? `S${season} · ` : ''}${
|
||||
epMatch[2]
|
||||
? `Ép. ${epMatch[1]}–${epMatch[2]}`
|
||||
: `Ép. ${epMatch[1]}`
|
||||
}`
|
||||
: null
|
||||
|
||||
|
||||
|
||||
const chapters = chapMatch
|
||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||
: null
|
||||
|
||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
||||
let cleanPlot = text
|
||||
if (episodes || chapters) {
|
||||
if (episodes || chapters || season) {
|
||||
cleanPlot = text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
return { episodes, chapters, cleanPlot }
|
||||
return { episodes, season, chapters, cleanPlot }
|
||||
}
|
||||
function parseEpChap(plot: string | null | undefined) {
|
||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
@@ -121,7 +133,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
defineProps<{
|
||||
@@ -136,6 +148,8 @@ defineProps<{
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -143,6 +157,7 @@ const emit = defineEmits<{
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface Serie {
|
||||
status: string | null
|
||||
torrent_count: number
|
||||
has_torrents: boolean
|
||||
download_state: 'none' | 'downloading' | 'partial' | 'complete'
|
||||
in_client: boolean
|
||||
has_files: boolean
|
||||
rss_synced: boolean
|
||||
}
|
||||
|
||||
export interface SerieDetail {
|
||||
|
||||
+54
-29
@@ -405,6 +405,16 @@ function integraleGroupLabels(): string[] {
|
||||
}
|
||||
|
||||
// É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> {
|
||||
const covered = new Set<number>()
|
||||
const t = data.value?.torrents_integrale[integraleIndex]
|
||||
@@ -503,6 +513,8 @@ async function fetchActiveDownloads() {
|
||||
const list: any[] = await res.json()
|
||||
|
||||
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) {
|
||||
const prevState = _prevTorrentStates.get(t.hash)
|
||||
@@ -515,7 +527,7 @@ async function fetchActiveDownloads() {
|
||||
_prevFileProgresses.set(key, f.progress)
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(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)
|
||||
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
|
||||
_recentlyOrganized.add(t.hash)
|
||||
triggerOrganize(t)
|
||||
toOrganize.set(t.hash, t)
|
||||
}
|
||||
|
||||
_prevTorrentStates.set(t.hash, t.state)
|
||||
}
|
||||
|
||||
for (const t of toOrganize.values()) triggerOrganize(t)
|
||||
|
||||
activeTorrents.value = list.map(t => ({
|
||||
hash : t.hash,
|
||||
progress : t.progress ?? 0,
|
||||
@@ -582,6 +596,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
const result: DlItem[] = []
|
||||
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) ───────────────────────────────────────────
|
||||
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
|
||||
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
|
||||
@@ -589,15 +609,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
|
||||
for (const { t, i } of integrales) {
|
||||
if (!t) continue
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
// Marquer les épisodes couverts par CETTE intégrale uniquement
|
||||
|
||||
// Épisodes apportés par CETTE intégrale
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
const coveredByThis = new Set<number>()
|
||||
if (hash)
|
||||
for (const season of data.value!.seasons)
|
||||
for (const ep of season.episodes)
|
||||
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 ──────────────────────────────
|
||||
@@ -606,12 +634,13 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
// 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))
|
||||
if (!hasUncovered) continue
|
||||
let addedAny = false
|
||||
season.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true }
|
||||
})
|
||||
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
if (!alreadyHandled) {
|
||||
const t = season.torrents[0]
|
||||
result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
for (const ep of season.episodes) covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
const hasSomethingToDownload = computed(() => {
|
||||
if (!data.value) return false
|
||||
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true
|
||||
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
|
||||
})
|
||||
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
|
||||
// que le garde-fou versions ne laisserait passer aucun torrent.
|
||||
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
|
||||
|
||||
async function downloadAll(integraleIndex?: number) {
|
||||
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 seasonAny = season as any
|
||||
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
|
||||
seasonAny.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) {
|
||||
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
})
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
if (!alreadyHandled) {
|
||||
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 {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
|
||||
|
||||
+102
-9
@@ -13,6 +13,8 @@
|
||||
:filters-import="filtersImport"
|
||||
:sort-options="sortOptions"
|
||||
:legend="legend"
|
||||
:selectable="auth.isAdmin"
|
||||
v-model:selecting="selecting"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
@@ -33,14 +35,22 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
: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="{ '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
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
@@ -52,10 +62,22 @@
|
||||
<Tv :size="28" />
|
||||
</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>
|
||||
</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
|
||||
class="absolute bottom-0 left-0 right-0 h-1"
|
||||
:style="{ background: stateColor(serie.download_state) }"
|
||||
@@ -77,7 +99,20 @@
|
||||
</span>
|
||||
</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>
|
||||
|
||||
@@ -85,10 +120,10 @@
|
||||
</template>
|
||||
|
||||
<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 { Tv } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { Tv, Check, Rss, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore, type Serie } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -117,6 +152,7 @@ const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
@@ -143,6 +179,7 @@ const filtered = computed(() => {
|
||||
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 === '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()) {
|
||||
const q = search.value.toLowerCase()
|
||||
@@ -163,6 +200,62 @@ const filtered = computed(() => {
|
||||
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 {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
|
||||
@@ -138,16 +138,25 @@
|
||||
<!-- Champs principaux -->
|
||||
<template v-if="currentDefinition">
|
||||
<div v-for="field in basicFields" :key="field.key">
|
||||
<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"
|
||||
<SettingsToggle
|
||||
v-if="field.type === 'boolean'"
|
||||
:model-value="toBool(modal.config[field.key])"
|
||||
:label="field.label"
|
||||
:description="fieldTooltip(field.key)"
|
||||
@update:model-value="modal.config[field.key] = $event"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 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-for="field in advancedFields" :key="field.key">
|
||||
<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=""
|
||||
class="settings-input"
|
||||
<SettingsToggle
|
||||
v-if="field.type === 'boolean'"
|
||||
:model-value="toBool(modal.config[field.key])"
|
||||
:label="field.label"
|
||||
:description="fieldTooltip(field.key)"
|
||||
@update:model-value="modal.config[field.key] = $event"
|
||||
/>
|
||||
<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>
|
||||
@@ -222,6 +240,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -254,11 +273,7 @@ const clientMatrix = [
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
|
||||
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
@@ -266,24 +281,30 @@ const modal = ref({
|
||||
uuid : null as string | null,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
config : {} as Record<string, string | number | boolean>,
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : false,
|
||||
})
|
||||
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath']
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
|
||||
|
||||
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.',
|
||||
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.',
|
||||
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 {
|
||||
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 currentDefinition = computed(() =>
|
||||
@@ -367,7 +388,8 @@ function onTypeChange() {
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
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>
|
||||
Actualiser
|
||||
</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
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
@@ -177,6 +185,7 @@ const { add: toast } = useToast()
|
||||
const loading = ref(true)
|
||||
const renamingAll = ref(false)
|
||||
const purgingNfo = ref(false)
|
||||
const forcingRefresh = ref(false)
|
||||
const series = ref<any[]>([])
|
||||
const nfoSupport = ref(false)
|
||||
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() {
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
|
||||
purgingNfo.value = true
|
||||
|
||||
@@ -103,6 +103,11 @@
|
||||
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."
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- Plex -->
|
||||
@@ -215,6 +220,7 @@ const form = ref({
|
||||
autoImport : true,
|
||||
deleteTorrentOnMove : false,
|
||||
autoUnimportMissing : false,
|
||||
englishDirectory : false,
|
||||
})
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.19.1
|
||||
3.22.0
|
||||
Reference in new issue
Block a user