fix: qbitorrent

This commit is contained in:
MasutaYunikon committed 2026-04-29 18:29:18 +02:00
1 parent c267562aec
commit 9834023522
7 files changed
+60 -33

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Claude Code
.claude/
# Logs
logs
*.log
+4 -3
View File
@@ -19,9 +19,10 @@ router.post('/download', requireAuth, async (req, res) => {
const url = torrent_url ?? magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_index != null ? ` (fichier #${file_index})` : ''}`)
const results = await dispatchDownload(url, { file_index, file_path, magnet })
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_path ? ` (${file_path})` : ''}`)
const results = await dispatchDownload(url, { file_index, file_path, infohash, magnet })
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
+2 -1
View File
@@ -59,7 +59,8 @@ export interface TorrentInfo {
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
magnet ?: string | null // conservé pour extraire le hash même quand on envoie le .torrent
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent
}
export interface TorrentClientDriver {
+38 -16
View File
@@ -48,9 +48,8 @@ async function qbTorrentExists(config: Record<string, string | number>, sid: str
}
/**
* 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é).
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* applique les priorités, puis reprend le torrent.
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
@@ -70,6 +69,10 @@ async function qbApplyFilePriority(
const files: any[] = await res.json()
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
}
// 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),
@@ -83,7 +86,8 @@ async function qbApplyFilePriority(
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}` } })
const r = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` } })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
}
}
@@ -92,9 +96,21 @@ async function qbApplyFilePriority(
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}` } })
const r2 = await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form2, headers: { Cookie: `SID=${sid}` } })
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Reprendre (torrent auto-mis en pause par stopCondition=MetadataReceived)
// Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: { Cookie: `SID=${sid}` } })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
continue
}
}
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeForm = new FormData()
@@ -107,7 +123,9 @@ async function qbApplyFilePriority(
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
return
} catch {}
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
@@ -216,10 +234,11 @@ const QB: TorrentClientDriver = {
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
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
if (options?.file_index != null) {
if (hash) {
@@ -242,12 +261,15 @@ const QB: TorrentClientDriver = {
} catch {}
}
// Ajout du torrent (stopCondition=MetadataReceived si hash connu, qBit ≥ 4.6)
// Ajout du torrent en pause immédiate (toutes versions qBit)
// paused=true → qBit < 5.0
// stopped=true → qBit ≥ 5.0
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))
if (hash) form.append('stopCondition', 'MetadataReceived')
form.append('paused', 'true')
form.append('stopped', 'true')
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
@@ -256,12 +278,12 @@ const QB: TorrentClientDriver = {
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, sélection fichier ${options.file_index} en attente)`)
logger.info('qbittorrent', `Torrent ajouté (en pause, 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}`)
)
} else {
// Hash inconnu (.torrent sans magnet) → retrouve le nouveau torrent par diff de liste
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
@@ -275,7 +297,7 @@ const QB: TorrentClientDriver = {
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, false)
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
return
} catch {}
}
+3 -3
View File
@@ -197,7 +197,7 @@
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null), epOptionsOpen = null)"
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
>
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
{{ groupLabels(ep.torrents)[i] }}
@@ -313,7 +313,7 @@ const props = defineProps<{
const emit = defineEmits<{
toggle : [id: number]
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null]
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
downloadSeason : [season: any, packHash?: string]
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
@@ -452,7 +452,7 @@ function handleEpBtnClick(ep: any) {
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null)
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
}
+2 -2
View File
@@ -100,12 +100,12 @@ export const useSeriesStore = defineStore('series', () => {
}
}
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null): Promise<{ success: boolean; error?: string }> {
async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null): Promise<{ success: boolean; error?: string }> {
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null })
body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? null, infohash: infohash ?? null })
})
const data = await res.json()
if (res.ok) return { success: true }
+8 -8
View File
@@ -100,7 +100,7 @@
:downloading-season="!!downloadingSeason[season.id]"
:nfo-support="nfoSupport"
@toggle="toggleSeason"
@download="(key, url, magnet, fi, fp) => download(key, url, magnet, fi, fp)"
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
@download-season="(s, h) => downloadSeason(s, h)"
@rename-episode="(ep, s) => renameEpisode(ep, s)"
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
@@ -329,16 +329,16 @@ async function triggerOrganize(torrent: any) {
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
// ── Download ──────────────────────────────────────────────────
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null) {
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet, file_index, file_path)
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
removeDownloading(key)
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Erreur inconnue', 'error')
}
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
// Construit la liste de tout ce qui doit être téléchargé.
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
@@ -384,7 +384,7 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const season of data.value.seasons)
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null })
result.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null, infohash: ep.torrent.infohash ?? null })
}
return result
@@ -408,7 +408,7 @@ async function downloadAll(integraleIndex?: number) {
const torrents = collectDownloadables(integraleIndex)
let sent = 0
for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
@@ -433,12 +433,12 @@ async function downloadSeason(season: Season, packHash?: string) {
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
: ep.torrent
if (!epTorrent || !ep.available || ep.organized || isAlreadyQueued(epTorrent) || isDownloaded(`ep-${ep.id}`)) continue
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null })
torrents.push({ key: `ep-${ep.id}`, torrent_url: epTorrent.torrent_url, magnet: epTorrent.magnet, file_index: epTorrent.file_index ?? null, file_path: epTorrent.file_path ?? null, infohash: epTorrent.infohash ?? null })
}
}
let sent = 0
for (const t of torrents) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }