/** * rTorrent Driver * =============== * Communique via XML-RPC (HTTP POST). * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). */ import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' // ─── XML-RPC helpers ────────────────────────────────────────── function xmlValue(val: any): string { if (typeof val === 'string') return `${val}` if (typeof val === 'number') return `${val}` if (typeof val === 'boolean') return `${val ? 1 : 0}` if (Array.isArray(val)) return `${val.map(xmlValue).join('')}` return `${String(val)}` } function xmlCall(method: string, params: any[] = []): string { return ` ${method} ${params.map(p => `${xmlValue(p)}`).join('')} ` } function parseXmlValue(node: Element): any { const child = node.firstElementChild if (!child) return node.textContent ?? '' switch (child.tagName) { case 'string' : return child.textContent ?? '' case 'int' : case 'i4' : case 'i8' : return parseInt(child.textContent ?? '0', 10) case 'double' : return parseFloat(child.textContent ?? '0') case 'boolean' : return child.textContent === '1' case 'array' : { const data = child.querySelector('data') if (!data) return [] return Array.from(data.children).map(parseXmlValue) } case 'struct' : { const result: Record = {} for (const member of Array.from(child.querySelectorAll(':scope > member'))) { const name = member.querySelector(':scope > name')?.textContent ?? '' const value = member.querySelector(':scope > value') if (name && value) result[name] = parseXmlValue(value) } return result } default: return child.textContent ?? '' } } async function rpcCall( config : Record, method : string, params : any[] = [], ): Promise { const body = xmlCall(method, params) const headers : Record = { 'Content-Type': 'text/xml' } if (config.username && config.password) { headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`) } const rpcPath = String(config.rpcPath || '/RPC2') const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}` const res = await fetch(url, { method: 'POST', headers, body }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const text = await res.text() // Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste const faultMatch = text.match(/faultString<\/name>\s*([^<]*)<\/string>/) if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`) const valueMatch = text.match(/\s*\s*\s*([\s\S]*?)<\/value>\s*<\/param>/) if (!valueMatch) return null // Parse simplifié pour les types courants const inner = valueMatch[1].trim() // Array if (inner.startsWith('')) { const items = [...inner.matchAll(/([\s\S]*?)<\/value>/g)] return items.map(m => parseInnerValue(m[1].trim())) } return parseInnerValue(inner) } function parseInnerValue(inner: string): any { if (inner.startsWith('')) { const items = [...inner.matchAll(/([\s\S]*?)<\/value>/g)] return items.map(m => parseInnerValue(m[1].trim())) } const strMatch = inner.match(/^([\s\S]*?)<\/string>$/) if (strMatch) return strMatch[1] const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/) if (intMatch) return parseInt(intMatch[1], 10) // Valeur brute sans tag if (!inner.includes('<')) return inner return inner } // multicall pour récupérer plusieurs champs en une requête async function d_multicall( config : Record, view : string, methods: string[], ): Promise { const params = [view, ...methods.map(m => `${m}=`)] const result = await rpcCall(config, 'd.multicall2', ['', ...params]) return Array.isArray(result) ? result : [] } async function rtTorrentExists(config: Record, hash: string): Promise { try { const rows = await d_multicall(config, 'main', ['d.hash']) return rows.some(r => String(r[0]).toLowerCase() === hash.toLowerCase()) } catch { return false } } /** * Attend les métadonnées via polling rapide (300 ms), applique les priorités. * Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers. * Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé. * On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence. */ async function rtApplyFilePriority( config : Record, hash : string, fileIndex: number, ): Promise { // 100 tentatives × 300 ms = 30 s max for (let attempt = 0; attempt < 100; attempt++) { await new Promise(r => setTimeout(r, 300)) try { // Récupérer taille ET priorité courante de chaque fichier const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority=']) const fileCount = Array.isArray(files) ? files.length : 0 if (fileCount === 0) continue // Si tous les fichiers sont à priorité normale (état initial), // on filtre tout sauf la cible. Sinon on active juste la cible. const isInitialState = files.every((f: any) => Number(f[1]) > 0) for (let i = 0; i < fileCount; i++) { if (i === fileIndex) { await rpcCall(config, 'f.priority.set', [hash, i, 1]) } else if (isInitialState) { await rpcCall(config, 'f.priority.set', [hash, i, 0]) } // Si pas initial state : on ne touche pas aux autres fichiers } logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`) return } catch {} } throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`) } // ─── State mapping ──────────────────────────────────────────── // rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1) // + is_open (0/1) function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] { if (isChecking) return 'checking' if (!isOpen) return 'paused' if (!isActive) return 'paused' if (isComplete) return 'seeding' return 'downloading' } const RT: TorrentClientDriver = { definition: { id : 'rtorrent', label : 'rTorrent', fields: [ { key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true }, { key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' }, { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false }, { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false }, { key: 'category', label: 'Catégorie (label)', 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 { const version = await rpcCall(config, 'system.client_version') logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`) return { ok: true, message: 'Connexion réussie' } } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) return { ok: false, message: msg } } }, async healthcheck(config) { try { const version = await rpcCall(config, 'system.client_version') logger.debug('rtorrent', `Healthcheck OK — version ${version}`) return { online: true, version: String(version) } } catch (err) { logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) return { online: false } } }, async list(config, category) { const methods = [ 'd.hash', 'd.name', 'd.is_open', 'd.is_active', 'd.is_hash_checking', 'd.complete', 'd.size_bytes', 'd.bytes_done', 'd.up.total', // total uploadé 'd.ratio', // ratio x1000 'd.down.rate', 'd.up.rate', 'd.left_bytes', 'd.directory', 'd.custom1', ] const rows = await d_multicall(config, 'main', methods) // Récupérer la progression par fichier pour chaque torrent (requêtes parallèles) const fileMap = new Map() await Promise.all(rows.map(async (r: any) => { const hash = String(r[0]).toUpperCase() try { const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=']) if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files) } catch {} })) return rows .map(r => { const [hash, name, isOpen, isActive, isChecking, isComplete, size, downloaded, uploadedTotal, ratioRaw, dlSpeed, ulSpeed, leftBytes, directory, label] = r const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0 const eta = Number(dlSpeed) > 0 && Number(leftBytes) > 0 ? Math.round(Number(leftBytes) / Number(dlSpeed)) : -1 const hashLower = String(hash).toLowerCase() const fileInfo = fileMap.get(hashLower) ?? [] return { hash : hashLower, name : String(name), state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)), progress, size : Number(size), downloaded: Number(downloaded), uploaded : Number(uploadedTotal ?? 0), ratio : Math.round((Number(ratioRaw ?? 0) / 1000) * 100) / 100, speed : Number(dlSpeed), upspeed : Number(ulSpeed ?? 0), eta, save_path : String(directory), category : String(label ?? ''), files : fileInfo.length > 0 ? fileInfo.map((f: any, i: number) => ({ index : i, progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0, })) : undefined, } satisfies TorrentInfo }) .filter(t => { if (!category) return true return t.category === category }) }, async add(config, url, options?: DownloadOptions) { const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) ?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) const hash = hashMatch?.[1]?.toUpperCase() ?? null if (options?.file_index != null && hash) { const exists = await rtTorrentExists(config, hash) if (exists) { // Torrent déjà présent → mettre à jour la priorité directement (async OK) logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`) rtApplyFilePriority(config, hash, options.file_index).catch(err => logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) ) return } // Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif) // Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé. // On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles. const args: any[] = ['', url] if (config.savePath) args.push(`d.directory.set="${config.savePath}"`) if (config.category) args.push(`d.custom1.set=${config.category}`) await rpcCall(config, 'load.start', args) logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`) rtApplyFilePriority(config, hash, options.file_index).catch(err => logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) ) return } // Pas de sélection de fichier → ajout et démarrage normal const args: any[] = ['', url] if (config.savePath) args.push(`d.directory.set="${config.savePath}"`) if (config.category) args.push(`d.custom1.set=${config.category}`) await rpcCall(config, 'load.start', args) logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) }, async remove(config, hash, deleteFiles = false) { const h = hash.toUpperCase() if (deleteFiles) { // Supprimer les fichiers puis effacer await rpcCall(config, 'd.delete_tied', [h]) await rpcCall(config, 'd.erase', [h]) } else { await rpcCall(config, 'd.erase', [h]) } logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) }, } export default RT