fix: qbitorrent
This commit is contained in:
1 parent
c267562aec
commit
9834023522
7 files changed
+60
-33
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user