From 0e09c85efddaaac933f40002fdcf81e92b6764d3 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Thu, 9 Apr 2026 15:59:55 +0200 Subject: [PATCH] feat: add uttorent driver fix: organiser take correct name --- server/index.ts | 3 + server/organize-worker.ts | 2 +- server/organize.ts | 68 ++++++++-- server/torrent-clients/utorrent.ts | 196 +++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 server/torrent-clients/utorrent.ts diff --git a/server/index.ts b/server/index.ts index a3b1090..5b4111d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,8 @@ import { import qbittorrentDriver from './torrent-clients/qbittorrent.js' import transmissionDriver from './torrent-clients/transmission.js' import synologyDsDriver from './torrent-clients/synology-ds.js' +import utorrentDriver from './torrent-clients/utorrent.js' + import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' @@ -21,6 +23,7 @@ import { systemInfo } from './system.js' registerDriver(qbittorrentDriver) registerDriver(transmissionDriver) registerDriver(synologyDsDriver) +registerDriver(utorrentDriver) const app = express() const PORT = Number(process.env.PORT) || 9898 diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 38a6c5d..9bd54f7 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -394,9 +394,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest) } else { await fsp.copyFile(src, dest) + markOrganized(hash, filename) await fsp.unlink(src) } - markOrganized(hash, filename) result.done++ debug(`${nfo_filename} → Saison ${season_number}`) } catch (err) { diff --git a/server/organize.ts b/server/organize.ts index 654cf17..d88faff 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -32,6 +32,12 @@ export interface OrganizeResult { // ── Scan initial ────────────────────────────────────────────── +function swapExt(filename: string, ext: string): string { + const cur = path.extname(filename) + if (!cur || cur === ext) return filename + return filename.slice(0, -cur.length) + ext +} + export async function scanMediaPath( mediaPath : string, organizedPath: string, @@ -46,7 +52,11 @@ export async function scanMediaPath( logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) - const filenameIndex = new Map() + // FIX : on indexe le nom brut ET les noms de destination possibles + // (nfo_filename, formatted_name) pour matcher les fichiers déjà renommés + // Map : nom_sur_disque → { hash, srcFilename } + const filenameIndex = new Map() + for (const sd of seriesData) { for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { @@ -54,8 +64,31 @@ export async function scanMediaPath( if (typeof p === 'string') continue const hash = p.infohash?.toLowerCase() if (!hash || !p.path) continue - const filename = p.path.replace(/\\/g, '/').split('/').pop() - if (filename) filenameIndex.set(filename, hash) + + const srcFilename = p.path.replace(/\\/g, '/').split('/').pop() + if (!srcFilename) continue + + const srcExt = path.extname(srcFilename) + + // 1. Nom brut du fichier source + filenameIndex.set(srcFilename, { hash, srcFilename }) + + // 2. nfo_filename avec extension swappée + if (ep.nfo_filename) { + const nfoRenamed = swapExt(ep.nfo_filename, srcExt) + filenameIndex.set(nfoRenamed, { hash, srcFilename }) + } + + // 3. original_filename + if (ep.original_filename) { + filenameIndex.set(ep.original_filename, { hash, srcFilename }) + } + + // 4. formatted_name + .mkv + if (ep.formatted_name?.trim()) { + const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv' + filenameIndex.set(fmtName, { hash, srcFilename }) + } } } } @@ -67,6 +100,8 @@ export async function scanMediaPath( organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) } catch {} + // FIX : on track les paires hash:srcFilename (clé dans organized.json) + // et non hash:nom_sur_disque const presentFiles = new Set() function walk(dir: string) { @@ -79,24 +114,35 @@ export async function scanMediaPath( walk(full) } else if (entry.isFile() && entry.name.endsWith('.mkv')) { result.found++ - const hash = filenameIndex.get(entry.name) - if (!hash) continue - presentFiles.add(`${hash}:${entry.name}`) - if (organized[hash]?.[entry.name]) continue + const match = filenameIndex.get(entry.name) + if (!match) continue + + const { hash, srcFilename } = match + // La clé dans organized.json est toujours le srcFilename (nom brut) + presentFiles.add(`${hash}:${srcFilename}`) + if (organized[hash]?.[srcFilename]) continue if (!organized[hash]) organized[hash] = {} - organized[hash][entry.name] = new Date().toISOString() + organized[hash][srcFilename] = new Date().toISOString() result.added++ + logger.debug('organize', `Scan match : "${entry.name}" → hash ${hash.slice(0, 8)}… (src: ${srcFilename})`) } } } walk(mediaPath) + // FIX : supprimer uniquement les entrées dont le fichier source + // n'est plus présent ET qui sont connues dans l'index + // (évite de supprimer des entrées pour des fichiers hors catalogue) + const allSrcFilenames = new Set([...filenameIndex.values()].map(v => v.srcFilename)) + let removed = 0 for (const [hash, files] of Object.entries(organized)) { - for (const filename of Object.keys(files)) { - if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) { - delete organized[hash][filename] + for (const srcFilename of Object.keys(files)) { + // Ne supprimer que si le fichier est dans notre catalogue + // ET qu'il n'est plus présent dans la médiathèque + if (allSrcFilenames.has(srcFilename) && !presentFiles.has(`${hash}:${srcFilename}`)) { + delete organized[hash][srcFilename] removed++ } } diff --git a/server/torrent-clients/utorrent.ts b/server/torrent-clients/utorrent.ts new file mode 100644 index 0000000..3437bc8 --- /dev/null +++ b/server/torrent-clients/utorrent.ts @@ -0,0 +1,196 @@ +/** + * uTorrent Driver + */ + +import type { TorrentClientDriver, TorrentInfo } from './index.js' +import { logger } from '../logger.js' + +// uTorrent status flags (bitmask) : +// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked, +// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded +function mapState(status: number): TorrentInfo['state'] { + if (status & 16) return 'error' + if (status & 32) return 'paused' + if (status & 2) return 'checking' + // Démarré + chargé + vérifié → on regarde le ratio de progression + if (status & 1 && status & 8) return 'downloading' + if (status & 64) return 'downloading' // queued + return 'unknown' +} + +// uTorrent nécessite un token CSRF + cookie à chaque session +interface UTSession { token: string; cookie: string } + +async function utGetSession(config: Record): Promise { + const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) + const headers : Record = { + 'Authorization': `Basic ${auth}`, + } + + const res = await fetch(`${config.url}/gui/token.html`, { headers }) + if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`) + + const text = await res.text() + const match = text.match(/]+id=['"]token['"][^>]*>([^<]+), + params : Record, + session: UTSession, +): Promise { + const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) + const qs = new URLSearchParams({ token: session.token, ...params }) + + const res = await fetch(`${config.url}/gui/?${qs}`, { + headers: { + 'Authorization': `Basic ${auth}`, + 'Cookie' : session.cookie, + }, + }) + if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`) + return res.json() +} + +function mapTorrent(t: any[]): TorrentInfo { + // uTorrent retourne un tableau par torrent : + // [hash, status, name, size, progress, downloaded, uploaded, ratio, + // ul_speed, dl_speed, eta, label, peers_connected, peers_swarm, + // seeds_connected, seeds_swarm, availability, queue_order, remaining] + const [hash, status, name, size, progress, downloaded, , , , dlSpeed, eta, label] = t + return { + hash : hash.toLowerCase(), + name, + state : progress >= 1000 ? 'seeding' : mapState(status), + progress : Math.min(100, Math.round(progress / 10)), + size, + downloaded, + speed : dlSpeed ?? 0, + eta : eta ?? -1, + save_path : '', // récupéré séparément via torrent properties si besoin + category : label ?? '', + } +} + +const UT: TorrentClientDriver = { + definition: { + id : 'utorrent', + label : 'uTorrent', + fields: [ + { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, + { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, + { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, + { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { 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 }, + ], + }, + + async test(config) { + try { + await utGetSession(config) + logger.info('utorrent', `Test de connexion réussi sur ${config.url}`) + return { ok: true, message: 'Connexion réussie' } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const session = await utGetSession(config) + const data = await utRequest(config, { action: 'getsettings' }, session) + const version = data?.['build']?.toString() ?? 'inconnue' + logger.debug('utorrent', `Healthcheck OK — build ${version}`) + return { online: true, version } + } catch (err) { + logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) + return { online: false } + } + }, + + async list(config, category) { + const session = await utGetSession(config) + const data = await utRequest(config, { list: '1' }, session) + const torrents: any[][] = data?.torrents ?? [] + + return torrents + .filter(t => { + if (!category) return true + return t[11] === category // index 11 = label + }) + .map(t => { + const info = mapTorrent(t) + + // Récupérer le save_path depuis les properties si disponible + const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash) + if (props) info.save_path = props[1] ?? '' + + return info + }) + }, + + async add(config, url) { + const session = await utGetSession(config) + const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) + + const form = new FormData() + form.append('torrent_url', url) + + const qs = new URLSearchParams({ + token : session.token, + action: 'add-url', + s : url, + }) + + if (config.savePath) { + qs.set('download_dir', '1') + qs.set('path', String(config.savePath)) + } + + const res = await fetch(`${config.url}/gui/?${qs}`, { + method : 'GET', + headers: { + 'Authorization': `Basic ${auth}`, + 'Cookie' : session.cookie, + }, + }) + + if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`) + + // Appliquer le label/catégorie si défini + if (config.category) { + // On ne peut pas set le label au moment de l'ajout, il faut attendre + // que le torrent soit chargé — on le fait via setprops après un délai + setTimeout(async () => { + try { + const s2 = await utGetSession(config) + const data = await utRequest(config, { list: '1' }, s2) + const torrents: any[][] = data?.torrents ?? [] + // Trouver le torrent le plus récemment ajouté sans label + const target = torrents.find(t => !t[11]) + if (target) { + await utRequest(config, { + action: 'setprops', + hash : target[0], + s : 'label', + v : String(config.category), + }, s2) + } + } catch {} + }, 3000) + } + + logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) + }, +} + +export default UT \ No newline at end of file