Files
FanKarr/server/torrent-clients/rtorrent.ts
T
MasutaYunikon 2a77a38655 feat(torrent-clients): ajout du driver Real-Debrid
feat(organize): vérification de complétion par fichier avant d'importer
- Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent
- Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver
- Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink

fix(organize): retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows)

fix(organize): suppression des entrées orphelines lors de la migration d'IDs
- Quand l'ancien ID est orphelin et le nouvel ID déjà présent, l'orphelin est supprimé au lieu d'être conservé

fix(qbittorrent): suppression de paused/stopped à l'ajout du torrent
- Le torrent est ajouté normalement pour permettre la récupération immédiate des métadonnées
- Premier poll de qbApplyFilePriority réduit à 100 ms

feat(serie): badges épisodes et chapitres extraits du synopsis
- Regex sur ep.plot pour extraire les plages "Ép. 628–634" et "Ch. 700–706"
- La phrase de couverture est retirée du synopsis affiché
- Badges violet (épisodes) et teal (chapitres) à côté des badges MULTI/VOSTFR

feat(serie): lien wiki dans le header de la série si serie.wiki est défini

fix(serie): header mobile — items-start + aspect-[2/3] sur le poster
2026-05-06 22:20:22 +02:00

347 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=', 'f.path='])
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,
}))
: 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