/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */ import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js' import { clientFetch } from './index.js' import { logger } from '../logger.js' // ─── XML-RPC ────────────────────────────────────────────────── 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 : ClientConfig, 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 clientFetch(config, url, { method: 'POST', headers, body }) if (!res.ok) throw new Error(`rTorrent a répondu HTTP ${res.status} : vérifiez l'URL et le chemin RPC`) const text = await res.text() // Pas de DOMParser sous Node : analyse XML minimale const faultMatch = text.match(/faultString<\/name>\s*([^<]*)<\/string>/) if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`) const valueMatch = text.match(/\s*\s*\s*([\s\S]*?)<\/value>\s*<\/param>/) if (!valueMatch) return null // Types courants uniquement const inner = valueMatch[1].trim() 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 } async function d_multicall( config : ClientConfig, 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: ClientConfig, 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 puis applique les priorités, avant le début du téléchargement. */ async function rtApplyFilePriority( config : ClientConfig, hash : string, fileIndex: number, ): Promise { for (let attempt = 0; attempt < 100; attempt++) { await new Promise(r => setTimeout(r, 300)) try { 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 // Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement 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]) } } logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`) return } catch {} } throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`) } // ─── États ──────────────────────────────────────────────────── 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: 'Nom d\'utilisateur', 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 de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false }, { key: 'localPath', label: 'Dossier vu par 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 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 inattendue, consultez les journaux' logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`) return { ok: false, message: msg } } }, async healthcheck(config) { try { const version = await rpcCall(config, 'system.client_version') logger.debug('rtorrent', `Client en ligne (version ${version})`) return { online: true, version: String(version) } } catch (err) { logger.debug('rtorrent', `Client injoignable : ${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', 'd.ratio', // ratio × 1000 'd.down.rate', 'd.up.rate', 'd.left_bytes', 'd.directory', 'd.custom1', ] const rows = await d_multicall(config, 'main', methods) 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=', 'f.path=', 'f.priority=']) 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, name : String(f[2] ?? ''), progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0, priority: Number(f[3] ?? 1) === 0 ? 0 : 1, })) : 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) { logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`) rtApplyFilePriority(config, hash, options.file_index).catch(err => logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`) ) return } // Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées 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é, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`) rtApplyFilePriority(config, hash, options.file_index).catch(err => logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`) ) return } 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é${config.savePath ? ` (dossier : ${config.savePath})` : ''}`) }, async remove(config, hash, deleteFiles = false) { const h = hash.toUpperCase() if (deleteFiles) { 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