From 247694ab3e96f1f67078bac06e095d82d4db5c5f Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Tue, 21 Apr 2026 10:17:53 +0200 Subject: [PATCH] feat: ajout des telechargments par episodes / saisons --- server/routes/downloads.ts | 6 ++-- server/routes/series.ts | 3 +- server/torrent-clients/index.ts | 11 ++++-- server/torrent-clients/qbittorrent.ts | 46 ++++++++++++++++++++++-- server/torrent-clients/rtorrent.ts | 28 +++++++++++++-- server/torrent-clients/synology-ds.ts | 5 +-- server/torrent-clients/transmission.ts | 8 +++-- server/torrent-clients/utorrent.ts | 5 +-- src/components/serie/SerieHero.vue | 2 +- src/components/serie/SerieSeasonCard.vue | 4 +-- src/stores/series.ts | 4 +-- src/views/SerieView.vue | 20 +++++------ 12 files changed, 111 insertions(+), 31 deletions(-) diff --git a/server/routes/downloads.ts b/server/routes/downloads.ts index 8c7792f..bc1e2b7 100644 --- a/server/routes/downloads.ts +++ b/server/routes/downloads.ts @@ -15,8 +15,10 @@ const router = Router() router.post('/download', requireAuth, async (req, res) => { const url = req.body.magnet ?? req.body.torrent_url if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } - logger.info('api', 'Téléchargement demandé') - const results = await dispatchDownload(url) + 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é${file_index != null ? ` (fichier #${file_index})` : ''}`) + const results = await dispatchDownload(url, { file_index, file_path }) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) }) diff --git a/server/routes/series.ts b/server/routes/series.ts index f4ec235..07d69fc 100644 --- a/server/routes/series.ts +++ b/server/routes/series.ts @@ -88,7 +88,8 @@ router.get('/series/:id', requireAuth, async (req, res) => { } for (const ep of season.episodes ?? []) { for (const t of (ep.torrents ?? [])) { - episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true } + const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) + episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null } availableEpisodeIds.add(ep.id) const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const isOrg = orgFiles[String(ep.id)] !== undefined diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 75270d5..4627e4d 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -50,11 +50,16 @@ export interface TorrentInfo { category : string } +export interface DownloadOptions { + file_index?: number | null + file_path?: string | null +} + export interface TorrentClientDriver { definition : TorrentClientDefinition test : (config: Record) => Promise<{ ok: boolean; message: string }> healthcheck : (config: Record) => Promise<{ online: boolean; version?: string }> - add : (config: Record, url: string) => Promise + add : (config: Record, url: string, options?: DownloadOptions) => Promise list : (config: Record, category?: string) => Promise remove : (config: Record, hash: string, deleteFiles?: boolean) => Promise } @@ -208,7 +213,7 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string): // ─── Dispatch download ───────────────────────────────────────────────────────── -export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> { +export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> { const clients = loadClients() if (clients.length === 0) { @@ -226,7 +231,7 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam continue } try { - await driver.add(client.config, url) + await driver.add(client.config, url, options) logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`) results.push({ uuid: client.uuid, name: client.name, ok: true }) } catch (err) { diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index cde0568..33f0d13 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -2,7 +2,7 @@ * qBittorrent Driver */ -import type { TorrentClientDriver, TorrentInfo } from './index.js' +import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' function mapState(state: string): TorrentInfo['state'] { @@ -34,6 +34,37 @@ 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)) + try { + const sid = await qbLogin(config) + const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { + headers: { Cookie: `SID=${sid}` }, + }) + if (!res.ok) continue + const files: any[] = await res.json() + if (!Array.isArray(files) || files.length === 0) continue + const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex) + if (unwanted.length > 0) { + const form = new FormData() + 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 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)}…`) + return + } catch {} + } + throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`) +} + const QB: TorrentClientDriver = { definition: { id : 'qbittorrent', @@ -103,7 +134,7 @@ const QB: TorrentClientDriver = { } satisfies TorrentInfo)) }, - async add(config, url) { + async add(config, url, options?: DownloadOptions) { const sid = await qbLogin(config) const form = new FormData() form.append('urls', url) @@ -117,6 +148,17 @@ const QB: TorrentClientDriver = { 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`) + } + } }, async remove(config, hash, deleteFiles = false) { diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index 1a1304f..583b8dc 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -5,7 +5,7 @@ * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC). */ -import type { TorrentClientDriver, TorrentInfo } from './index.js' +import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' // ─── XML-RPC helpers ────────────────────────────────────────── @@ -220,7 +220,7 @@ const RT: TorrentClientDriver = { }) }, - async add(config, url) { + 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] @@ -234,6 +234,30 @@ const RT: TorrentClientDriver = { } 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)}…`) + })() + } + } logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) }, diff --git a/server/torrent-clients/synology-ds.ts b/server/torrent-clients/synology-ds.ts index 98c7473..b4db9bb 100644 --- a/server/torrent-clients/synology-ds.ts +++ b/server/torrent-clients/synology-ds.ts @@ -2,7 +2,7 @@ * Synology Download Station Driver */ -import type { TorrentClientDriver, TorrentInfo } from './index.js' +import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' // Synology Download Station task status codes @@ -130,7 +130,8 @@ const DS: TorrentClientDriver = { }) }, - async add(config, url) { + async add(config, url, options?: DownloadOptions) { + if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet') const sid = await dsLogin(config) const extra: Record = { uri: url } diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index da98598..5c50a11 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -2,7 +2,7 @@ * Transmission Driver */ -import type { TorrentClientDriver, TorrentInfo } from './index.js' +import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' function mapState(status: number): TorrentInfo['state'] { @@ -122,7 +122,7 @@ const TR: TorrentClientDriver = { } satisfies TorrentInfo)) }, - async add(config, url) { + async add(config, url, options?: DownloadOptions) { const args: Record = { filename: url } if (config.savePath) { @@ -133,6 +133,10 @@ const TR: TorrentClientDriver = { args['labels'] = [String(config.category)] } + 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}` : ''})`) }, diff --git a/server/torrent-clients/utorrent.ts b/server/torrent-clients/utorrent.ts index 7fe5c61..eba04a0 100644 --- a/server/torrent-clients/utorrent.ts +++ b/server/torrent-clients/utorrent.ts @@ -2,7 +2,7 @@ * uTorrent Driver */ -import type { TorrentClientDriver, TorrentInfo } from './index.js' +import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js' import { logger } from '../logger.js' // uTorrent status flags (bitmask) : @@ -141,7 +141,8 @@ const UT: TorrentClientDriver = { }) }, - async add(config, url) { + async add(config, url, options?: DownloadOptions) { + if (options?.file_index != null) logger.warn('utorrent', 'Sélection de fichier non supportée — téléchargement complet') const session = await utGetSession(config) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) diff --git a/src/components/serie/SerieHero.vue b/src/components/serie/SerieHero.vue index fad1246..ac74636 100644 --- a/src/components/serie/SerieHero.vue +++ b/src/components/serie/SerieHero.vue @@ -147,7 +147,7 @@ const emit = defineEmits<{ openManualImport: [] unimport : [deleteFile: boolean] downloadAll : [] - download : [key: string, url: string | null, magnet: string | null] + download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null] }>() const unimportMenuOpen = ref(false) diff --git a/src/components/serie/SerieSeasonCard.vue b/src/components/serie/SerieSeasonCard.vue index 3bf5994..6f819ff 100644 --- a/src/components/serie/SerieSeasonCard.vue +++ b/src/components/serie/SerieSeasonCard.vue @@ -102,7 +102,7 @@ class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors" :class="epBtnClass(ep)" :disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)" - @click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)" + @click="ep.torrent && !ep.organized && !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)" > @@ -171,7 +171,7 @@ const props = defineProps<{ const emit = defineEmits<{ toggle : [id: number] - download : [key: string, url: string | null, magnet: string | null] + download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null] downloadSeason : [season: any] renameEpisode : [ep: any, season: any] unimportEpisode: [ep: any, season: any, deleteFile: boolean] diff --git a/src/stores/series.ts b/src/stores/series.ts index 93dcf0e..9cc5c2c 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): Promise<{ success: boolean; error?: string }> { + async function download(torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: 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 }) + body: JSON.stringify({ torrent_url, magnet, file_index: file_index ?? null, file_path: file_path ?? 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 afa51c1..1e90c6a 100644 --- a/src/views/SerieView.vue +++ b/src/views/SerieView.vue @@ -27,7 +27,7 @@ @open-manual-import="openManualImport" @unimport="(del) => unimportSerie(del)" @download-all="downloadAll" - @download="(key, url, magnet) => download(key, url, magnet)" + @download="(key, url, magnet, fi, fp) => download(key, url, magnet, fi, fp)" /> @@ -45,7 +45,7 @@ :downloading-season="!!downloadingSeason[season.id]" :nfo-support="nfoSupport" @toggle="toggleSeason" - @download="(key, url, magnet) => download(key, url, magnet)" + @download="(key, url, magnet, fi, fp) => download(key, url, magnet, fi, fp)" @download-season="downloadSeason" @rename-episode="(ep, s) => renameEpisode(ep, s)" @unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)" @@ -146,10 +146,10 @@ async function fetchActiveDownloads() { function openManualImport() { fetchOrganized(); manualImportOpen.value = true } // ── Download ────────────────────────────────────────────────── -async function download(key: string, torrent_url: string | null, magnet: string | null) { +async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null) { if (isDownloading(key) || isDownloaded(key)) return addDownloading(key) - const result = await store.download(torrent_url, magnet) + const result = await store.download(torrent_url, magnet, file_index, file_path) removeDownloading(key) if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() } else toast(result.error ?? 'Erreur inconnue', 'error') @@ -157,7 +157,7 @@ async function download(key: string, torrent_url: string | null, magnet: string function collectDownloadables() { if (!data.value) return [] - const result: { key: string; torrent_url: string | null; magnet: string | null }[] = [] + const result: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = [] const covered = new Set() data.value.torrents_integrale.forEach((t: any, i: number) => { if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`)) { @@ -173,7 +173,7 @@ function collectDownloadables() { 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 }) + 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 }) } } return result @@ -204,7 +204,7 @@ async function downloadAll() { const torrents = collectDownloadables() let sent = 0 for (const t of torrents) { - try { const r = await store.download(t.torrent_url, t.magnet); 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); if (r.success) { addDownloaded(t.key); sent++ } } catch {} } downloadingAll.value = false if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() } @@ -213,18 +213,18 @@ async function downloadAll() { async function downloadSeason(season: Season) { downloadingSeason.value[season.id] = true - const torrents: { key: string; torrent_url: string | null; magnet: string | null }[] = [] + const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = [] if (season.torrent && !isAlreadyQueued(season.torrent) && !isDownloaded(`season-${season.id}`) && season.organized_state !== 'complete') { torrents.push({ key: `season-${season.id}`, torrent_url: season.torrent.torrent_url, magnet: season.torrent.magnet }) } else { for (const ep of (season as any).episodes ?? []) { if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`)) continue - torrents.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet }) + torrents.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 }) } } let sent = 0 for (const t of torrents) { - try { const r = await store.download(t.torrent_url, t.magnet); 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); if (r.success) { addDownloaded(t.key); sent++ } } catch {} } downloadingSeason.value[season.id] = false if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }