Files
FanKarr/server/torrent-clients/qbittorrent.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

289 lines
13 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.
/**
* qBittorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
if (['pausedDL', 'pausedUP', 'stoppedDL', 'stoppedUP'].includes(state)) return 'paused'
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
if (state === 'error' || state === 'missingFiles') return 'error'
return 'unknown'
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? ''))
const res = await fetch(`${config.url}/api/v2/auth/login`, {
method : 'POST',
body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
}
async function qbTorrentExists(config: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) return false
const list = await res.json()
return Array.isArray(list) && list.length > 0
} catch {
return false
}
}
/**
* Attend que les métadonnées soient disponibles, applique les priorités, puis reprend le torrent.
* Utilisé après un ajout avec stopCondition=MetadataReceived (qBit actif → auto-pause après metadata)
* ou pour un torrent déjà présent (resume=false, juste mise à jour de priorité).
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
resume : boolean,
): Promise<void> {
// 60 tentatives × 500 ms = 30 s max
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) continue
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
// Si tous les fichiers sont à priorité normale (état initial du torrent),
// on applique le filtrage complet : on désactive tout sauf la cible.
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
// on se contente d'activer la nouvelle cible sans toucher aux autres.
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
if (unwanted.length > 0) {
const form = new FormData()
form.append('hash', hash)
form.append('id', unwanted.join('|'))
form.append('priority', '0')
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` } })
}
}
// Activer le fichier cible (dans tous les cas)
const form2 = new FormData()
form2.append('hash', hash)
form2.append('id', String(fileIndex))
form2.append('priority', '1')
await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form2, headers: { Cookie: `SID=${sid}` } })
// Reprendre (torrent auto-mis en pause par stopCondition=MetadataReceived)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeForm = new FormData()
resumeForm.append('hashes', hash)
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
])
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const sid = await qbLogin(config)
const resumeForm = new FormData()
resumeForm.append('hashes', hash)
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const QB: TorrentClientDriver = {
definition: {
id : 'qbittorrent',
label : 'qBittorrent',
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 qbLogin(config)
logger.info('qbittorrent', `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('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const sid = await qbLogin(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
const data: any[] = await res.json()
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => {
try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (r.ok) fileMap.set(t.hash, await r.json())
} catch {}
}))
return data.map(t => {
const fileInfo = fileMap.get(t.hash) ?? []
return {
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
uploaded : t.uploaded ?? 0,
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
speed : t.dlspeed,
upspeed : t.upspeed ?? 0,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({ index: i, progress: Math.round((f.progress ?? 0) * 100) }))
: undefined,
} satisfies TorrentInfo
})
},
async add(config, url, options?: DownloadOptions) {
const sid = await qbLogin(config)
// Extraire le hash depuis l'URL (si magnet) ou depuis options.magnet (si .torrent URL)
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]?.toLowerCase() ?? null
if (options?.file_index != null && hash) {
const exists = await qbTorrentExists(config, sid, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Nouveau torrent → ajout actif avec stopCondition=MetadataReceived
// qBit fetch les métadonnées normalement via DHT/peers,
// puis se met en pause automatiquement dès qu'elles sont disponibles.
// On set les priorités sur la pause, puis on reprend. Zéro data indésirable.
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('stopCondition', 'MetadataReceived') // qBit ≥ 4.6
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, sélection fichier ${options.file_index} en attente)`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout normal
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
export default QB