feat: ajout des telechargments par episodes / saisons

This commit is contained in:
MasutaYunikon committed 2026-04-21 10:17:53 +02:00
1 parent 215379eca4
commit 247694ab3e
12 files changed
+111 -31

No files matched your search

+4 -2
View File
@@ -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 })
})
+2 -1
View File
@@ -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
+8 -3
View File
@@ -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<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
}
@@ -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) {
+44 -2
View File
@@ -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<string, string | number>): Promise<string>
return sid
}
async function qbSetFilePriority(config: Record<string, string | number>, hash: string, fileIndex: number): Promise<void> {
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) {
+26 -2
View File
@@ -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})` : ''}`)
},
+3 -2
View File
@@ -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<string, string> = { uri: url }
+6 -2
View File
@@ -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<string, unknown> = { 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}` : ''})`)
},
+3 -2
View File
@@ -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 ?? ''}`)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)"
>
<component :is="epStateIcon(ep)" />
</button>
@@ -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]
+2 -2
View File
@@ -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 }
+10 -10
View File
@@ -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)"
/>
<!-- Saisons -->
@@ -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<number>()
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() }