diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 33f0d13..441f6b5 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -8,7 +8,7 @@ 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'].includes(state)) return 'paused' + 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' @@ -34,9 +34,32 @@ async function qbLogin(config: Record): Promise return sid } -async function qbSetFilePriority(config: Record, hash: string, fileIndex: number): Promise { - for (let attempt = 0; attempt < 15; attempt++) { - await new Promise(r => setTimeout(r, 2000)) +async function qbTorrentExists(config: Record, sid: string, hash: string): Promise { + 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 les métadonnées, applique les priorités de fichiers, puis reprend le torrent si `resume=true`. + * - resume=false : torrent déjà actif, on met juste à jour la priorité + * - resume=true : torrent ajouté en pause, on le reprend après avoir défini les priorités + */ +async function qbApplyFilePriority( + config : Record, + hash : string, + fileIndex: number, + resume : boolean, +): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 1000)) try { const sid = await qbLogin(config) const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { @@ -45,6 +68,8 @@ async function qbSetFilePriority(config: Record, hash: if (!res.ok) continue const files: any[] = await res.json() if (!Array.isArray(files) || files.length === 0) continue + + // Désactiver tous les autres fichiers const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex) if (unwanted.length > 0) { const form = new FormData() @@ -53,15 +78,36 @@ async function qbSetFilePriority(config: Record, hash: form.append('priority', '0') await fetch(`${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` } }) } + + // Activer le fichier cible 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}` } }) - logger.info('qbittorrent', `Priorité fichier ${fileIndex} définie pour ${hash.slice(0, 8)}…`) + + // Reprendre si le torrent était en pause + if (resume) { + const resumeForm = new FormData() + resumeForm.append('hashes', hash) + await fetch(`${config.url}/api/v2/torrents/resume`, { 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é en pause + if (resume) { + try { + const sid = await qbLogin(config) + const resumeForm = new FormData() + resumeForm.append('hashes', hash) + await fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeForm, headers: { Cookie: `SID=${sid}` } }) + } catch {} + } + throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`) } @@ -135,30 +181,56 @@ const QB: TorrentClientDriver = { }, async add(config, url, options?: DownloadOptions) { - const sid = await qbLogin(config) + const sid = await qbLogin(config) + + const hashMatch = url.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 en pause pour éviter tout téléchargement avant la sélection + 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('paused', 'true') // qBit < 5.0 + form.append('stopped', 'true') // qBit ≥ 5.0 + + 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é en pause (sélection fichier ${options.file_index} en attente de métadonnées)`) + 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 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é avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) - if (options?.file_index != null) { - const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) - const hash = hashMatch?.[1]?.toLowerCase() ?? null - if (hash) { - qbSetFilePriority(config, hash, options.file_index).catch(err => - logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) - ) - } else { - logger.warn('qbittorrent', `file_index ignoré : hash non extractible depuis l'URL`) - } - } + logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, async remove(config, hash, deleteFiles = false) { @@ -176,4 +248,4 @@ const QB: TorrentClientDriver = { }, } -export default QB \ No newline at end of file +export default QB diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index 583b8dc..8547b7e 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -118,6 +118,55 @@ async function d_multicall( return Array.isArray(result) ? result : [] } +async function rtTorrentExists(config: Record, hash: string): Promise { + 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, applique les priorités, puis démarre le torrent si `start=true`. + * - start=false : torrent déjà actif, on met juste à jour la priorité + * - start=true : torrent chargé sans démarrer (load.normal), on le démarre après les priorités + */ +async function rtApplyFilePriority( + config : Record, + hash : string, + fileIndex: number, + start : boolean, +): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + await new Promise(r => setTimeout(r, 1000)) + try { + const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=']) + const fileCount = Array.isArray(files) ? files.length : 0 + if (fileCount === 0) continue + + for (let i = 0; i < fileCount; i++) { + await rpcCall(config, 'f.priority.set', [hash, i, i === fileIndex ? 1 : 0]) + } + + if (start) { + await rpcCall(config, 'd.start', [hash]) + } + + logger.info('rtorrent', `Fichier ${fileIndex} sélectionné${start ? ' + démarrage' : ''} pour ${hash.slice(0, 8)}…`) + return + } catch {} + } + + // Timeout : démarrer quand même pour ne pas laisser le torrent bloqué + if (start) { + try { await rpcCall(config, 'd.start', [hash]) } catch {} + logger.warn('rtorrent', `Timeout : démarré sans sélection de fichier pour ${hash.slice(0, 8)}…`) + } else { + 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) @@ -221,43 +270,40 @@ const RT: TorrentClientDriver = { }, async add(config, url, options?: DownloadOptions) { - // load_start charge et démarre le torrent - const method = url.startsWith('magnet:') ? 'load.start' : 'load.start' - const args: any[] = ['', url] + const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) + const hash = hashMatch?.[1]?.toUpperCase() ?? null - if (config.savePath) { - args.push(`d.directory.set="${config.savePath}"`) - } + if (options?.file_index != null && hash) { + const exists = await rtTorrentExists(config, hash) - if (config.category) { - args.push(`d.custom1.set=${config.category}`) - } - - await rpcCall(config, method, args) - if (options?.file_index != null) { - // Wait for torrent metadata then set file priorities - const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i) - const hash = hashMatch?.[1]?.toUpperCase() ?? null - if (hash) { - ;(async () => { - for (let attempt = 0; attempt < 15; attempt++) { - await new Promise(r => setTimeout(r, 2000)) - try { - const fileCount: number = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=']) - .then((r: any) => Array.isArray(r) ? r.length : 0) - if (fileCount === 0) continue - for (let i = 0; i < fileCount; i++) { - const priority = i === options.file_index ? 1 : 0 - await rpcCall(config, 'f.priority.set', [hash, i, priority]) - } - logger.info('rtorrent', `Priorité fichier ${options.file_index} définie pour ${hash.slice(0, 8)}…`) - return - } catch {} - } - logger.warn('rtorrent', `Priorité fichier non appliquée : timeout pour ${hash.slice(0, 8)}…`) - })() + 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, false).catch(err => + logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) + ) + return } + + // Nouveau torrent → charger sans démarrer pour éviter tout téléchargement avant la sélection + 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.normal', args) + + logger.info('rtorrent', `Torrent chargé sans démarrage (sélection fichier ${options.file_index} en attente de métadonnées)`) + rtApplyFilePriority(config, hash, options.file_index, true).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})` : ''}`) }, @@ -274,4 +320,4 @@ const RT: TorrentClientDriver = { }, } -export default RT \ No newline at end of file +export default RT diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index 5c50a11..acb4ca0 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -125,20 +125,34 @@ const TR: TorrentClientDriver = { async add(config, url, options?: DownloadOptions) { const args: Record = { filename: url } - if (config.savePath) { - args['download-dir'] = String(config.savePath) - } - - if (config.category) { - args['labels'] = [String(config.category)] - } + if (config.savePath) args['download-dir'] = String(config.savePath) + if (config.category) args['labels'] = [String(config.category)] + // Transmission applique files-wanted après récupération des métadonnées, + // sans démarrer les autres fichiers entre-temps — natif et fiable. if (options?.file_index != null) { args['files-wanted'] = [options.file_index] } - await trRequest(config, 'torrent-add', args) - logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) + const res = await trRequest(config, 'torrent-add', args) + + // Torrent déjà présent → Transmission renvoie torrent-duplicate + // On s'assure quand même que le fichier voulu est activé + if (options?.file_index != null && res.arguments?.['torrent-duplicate']) { + const dup = res.arguments['torrent-duplicate'] + try { + await trRequest(config, 'torrent-set', { + ids : [dup.id], + 'files-wanted': [options.file_index], + }) + logger.info('transmission', `Torrent ${dup.hashString?.slice(0, 8)}… déjà présent, fichier ${options.file_index} activé`) + } catch (err) { + logger.warn('transmission', `Activation fichier sur torrent existant échouée : ${err instanceof Error ? err.message : err}`) + } + return + } + + logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, async remove(config, hash, deleteFiles = false) { @@ -152,4 +166,4 @@ const TR: TorrentClientDriver = { }, } -export default TR \ No newline at end of file +export default TR diff --git a/src/views/settings/DownloadClientView.vue b/src/views/settings/DownloadClientView.vue index a1f5650..9177678 100644 --- a/src/views/settings/DownloadClientView.vue +++ b/src/views/settings/DownloadClientView.vue @@ -78,8 +78,8 @@

- ✓ Natif — appliqué à l'ajout · - Async — appliqué après récupération des métadonnées · + ✓ Natif — appliqué à l'ajout, aucun fichier superflu téléchargé · + Async (pause) — ajout en pause, sélection après métadonnées, puis reprise · ✗ — non supporté, torrent complet téléchargé

@@ -163,11 +163,11 @@ const availableClients = ref([]) const healthStatus = ref>({}) const clientMatrix = [ - { id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async', categories: true }, - { id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true }, - { id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async', categories: true }, - { id: 'utorrent', label: 'uTorrent', fileSelect: '✗', categories: true }, - { id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false }, + { id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async (pause)', categories: true }, + { id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true }, + { id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async (pause)', categories: true }, + { id: 'utorrent', label: 'uTorrent', fileSelect: '✗', categories: true }, + { id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false }, ] const modal = ref({