Files
FanKarr/server/torrent-clients/rtorrent.ts
T
MasutaYunikon f897116e30 feat: ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0
feat: ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs

fix: utilisation du fichier torrent avec le magnet pour les bug de metadonnées

fix: prise en charge de multiple torrent sur les telechargement d'episodes
2026-04-21 23:08:41 +02:00

346 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `<value><string>${val}</string></value>`
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
return `<value><string>${String(val)}</string></value>`
}
function xmlCall(method: string, params: any[] = []): string {
return `<?xml version="1.0"?>
<methodCall>
<methodName>${method}</methodName>
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
</methodCall>`
}
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<string, any> = {}
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<string, string | number>,
method : string,
params : any[] = [],
): Promise<any> {
const body = xmlCall(method, params)
const headers : Record<string, string> = { '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(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
// Parse simplifié pour les types courants
const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
return parseInnerValue(inner)
}
function parseInnerValue(inner: string): any {
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
const strMatch = inner.match(/^<string>([\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<string, string | number>,
view : string,
methods: string[],
): Promise<any[][]> {
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<string, string | number>, hash: string): Promise<boolean> {
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<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
// 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<string, any[]>()
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