diff --git a/server/routes/import.ts b/server/routes/import.ts index dd098fc..9933e87 100644 --- a/server/routes/import.ts +++ b/server/routes/import.ts @@ -239,6 +239,36 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => { res.json({ ok: true, removed, errors }) }) +// ── Désimport saison ─────────────────────────────────────────── +router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => { + const serieId = String(req.params.serieId) + const seasonId = Number(req.params.seasonId) + const deleteFile = req.query.deleteFile === 'true' + const organized = loadOrganizedJson() + const sd = await readSerieData(Number(serieId)) + if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return } + const season = sd.seasons?.find((s: any) => s.id === seasonId) + if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } + const episodeIds = new Set(season.episodes?.map((e: any) => String(e.id)) ?? []) + let removed = 0 + const errors: string[] = [] + for (const [hash, episodes] of Object.entries(organized)) { + for (const epId of Object.keys(episodes)) { + if (!episodeIds.has(epId)) continue + const entry = episodes[epId] + if (deleteFile && entry?.dest_path && fs.existsSync(entry.dest_path)) { + try { fs.unlinkSync(entry.dest_path) } catch { errors.push(entry.dest_path) } + } + delete organized[hash][epId] + removed++ + } + if (Object.keys(organized[hash]).length === 0) delete organized[hash] + } + saveOrganizedJson(organized) + logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés`) + res.json({ ok: true, removed, errors }) +}) + // ── Désimport épisode ────────────────────────────────────────── router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => { const episodeId = String(req.params.episodeId) diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index dcfa9ee..e579801 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -221,27 +221,33 @@ const QB: TorrentClientDriver = { ?? 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 + if (options?.file_index != null) { + if (hash) { + const exists = await qbTorrentExists(config, sid, hash) + if (exists) { + 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. + // Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu) + let knownHashes: Set = new Set() + if (!hash) { + try { + const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } }) + if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase())) + } catch {} + } + + // Ajout du torrent (stopCondition=MetadataReceived si hash connu, qBit ≥ 4.6) 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 + if (hash) form.append('stopCondition', 'MetadataReceived') const res = await fetch(`${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: { Cookie: `SID=${sid}` }, @@ -249,10 +255,33 @@ const QB: TorrentClientDriver = { 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}`) - ) + if (hash) { + 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}`) + ) + } else { + // Hash inconnu (.torrent sans 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 () => { + for (let attempt = 0; attempt < 20; attempt++) { + await new Promise(r => setTimeout(r, 500)) + try { + const s = await qbLogin(config) + const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } }) + if (!lr.ok) continue + const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase())) + 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) + return + } catch {} + } + logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`) + })().catch(() => {}) + } return } diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index 09fdcd2..1928f9e 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -52,6 +52,44 @@ async function trRequest( return { ...data, sessionId } } +async function trGetIdByHash(config: Record, hash: string): Promise { + const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] }) + const torrents = data.arguments?.torrents ?? [] + return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null +} + +/** + * Attend que les métadonnées soient disponibles (polling 500 ms), + * puis applique les priorités : fichier cible voulu, tous les autres ignorés. + */ +async function trApplyFilePriority( + config : Record, + hash : string, + fileIndex: number, +): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + await new Promise(r => setTimeout(r, 500)) + try { + const id = await trGetIdByHash(config, hash) + if (id == null) continue + + const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] }) + const files = data.arguments?.torrents?.[0]?.files + if (!Array.isArray(files) || files.length === 0) continue + + const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex) + await trRequest(config, 'torrent-set', { + ids : [id], + 'files-wanted' : [fileIndex], + 'files-unwanted' : unwanted, + }) + logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`) + return + } catch {} + } + throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`) +} + const TR: TorrentClientDriver = { definition: { id : 'transmission', @@ -96,7 +134,7 @@ const TR: TorrentClientDriver = { 'hashString', 'name', 'status', 'percentDone', 'totalSize', 'downloadedEver', 'uploadedEver', 'uploadRatio', 'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels', - 'fileStats', // progression par fichier (bytesCompleted + length) + 'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length') ] const data = await trRequest(config, 'torrent-get', { fields }) const torrents: any[] = data.arguments?.torrents ?? [] @@ -120,42 +158,36 @@ const TR: TorrentClientDriver = { eta : t.eta ?? -1, save_path : t.downloadDir, category : t.labels?.[0] ?? '', - files : Array.isArray(t.fileStats) && t.fileStats.length > 0 - ? t.fileStats.map((fs: any, i: number) => ({ + files : Array.isArray(t.files) && t.files.length > 0 + ? t.files.map((f: any, i: number) => ({ index : i, - progress: fs.length > 0 ? Math.round((fs.bytesCompleted / fs.length) * 100) : 0, + progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0, })) : undefined, } satisfies TorrentInfo)) }, async add(config, url, options?: DownloadOptions) { - const args: Record = { filename: 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 + const args: Record = { filename: url } 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] - } - 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}`) + if (options?.file_index != null && hash) { + const isDuplicate = !!res.arguments?.['torrent-duplicate'] + if (isDuplicate) { + logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`) + } else { + logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`) } + trApplyFilePriority(config, hash, options.file_index).catch(err => + logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) + ) return } diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index 76bf951..3758ee0 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -31,6 +31,15 @@
+ +