diff --git a/.gitignore b/.gitignore index f1a07ac..4c1c0c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code +.claude/ + # Logs logs *.log diff --git a/server/routes/downloads.ts b/server/routes/downloads.ts index d4266e2..701fde3 100644 --- a/server/routes/downloads.ts +++ b/server/routes/downloads.ts @@ -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 }) }) diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 8a9708f..aa5e981 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -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 { diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index e579801..3d1d5dd 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -48,9 +48,8 @@ async function qbTorrentExists(config: Record, 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, @@ -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 {} } diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index 3758ee0..bc100ac 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -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)" > {{ 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) } } diff --git a/src/stores/series.ts b/src/stores/series.ts index 9cc5c2c..b2ccdc1 100644 --- a/src/stores/series.ts +++ b/src/stores/series.ts @@ -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 } diff --git a/src/views/SerieView.vue b/src/views/SerieView.vue index 08b587c..e5a2877 100644 --- a/src/views/SerieView.vue +++ b/src/views/SerieView.vue @@ -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() }