From 392f73692554878003acb29dd50b707a68531a16 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Sat, 11 Apr 2026 12:51:31 +0200 Subject: [PATCH] feat: ajout du bouton copie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: ajout de l'option supprimé le torrent si l'option déplacer est activé feat: ajout de la suppression des torrents depuis l'interface --- server/index.ts | 44 +++++++++--- server/settings.ts | 2 +- server/torrent-clients/index.ts | 21 +++++- server/torrent-clients/qbittorrent.ts | 14 ++++ server/torrent-clients/rtorrent.ts | 12 ++++ server/torrent-clients/synology-ds.ts | 21 ++++++ server/torrent-clients/transmission.ts | 10 +++ server/torrent-clients/utorrent.ts | 9 +++ src/views/DownloadsView.vue | 78 +++++++++++++++++++--- src/views/settings/MediaManagementView.vue | 27 ++++++-- 10 files changed, 211 insertions(+), 27 deletions(-) diff --git a/server/index.ts b/server/index.ts index 029cca3..88f8022 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,7 @@ import { readSettings, writeSettings } from './settings.js' import { registerDriver, getDriver, getAvailableClients, listClients, addClient, removeClient, getClient, - sanitizeClient, dispatchDownload, dispatchList, updateClient + sanitizeClient, dispatchDownload, dispatchList, updateClient, dispatchRemove } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' import transmissionDriver from './torrent-clients/transmission.js' @@ -54,15 +54,16 @@ app.get('/api/settings', requireAuth, (_req, res) => { res.json(readSettings()) }) app.post('/api/settings', requireAuth, (req, res) => { - const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body + const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove } = req.body const patch: Record = {} - if (mediaPath !== undefined) patch.mediaPath = mediaPath - if (completePath !== undefined) patch.completePath = completePath - if (organizeMode !== undefined) patch.organizeMode = organizeMode - if (category !== undefined) patch.category = category - if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport - if (autoImport !== undefined) patch.autoImport = autoImport - if (devMode !== undefined) patch.devMode = devMode + if (mediaPath !== undefined) patch.mediaPath = mediaPath + if (completePath !== undefined) patch.completePath = completePath + if (organizeMode !== undefined) patch.organizeMode = organizeMode + if (category !== undefined) patch.category = category + if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport + if (autoImport !== undefined) patch.autoImport = autoImport + if (devMode !== undefined) patch.devMode = devMode + if (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove const updated = writeSettings(patch) logger.info('api', 'Paramètres mis à jour') res.json(updated) @@ -426,6 +427,19 @@ app.post('/api/download', requireAuth, async (req, res) => { res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) }) +app.delete('/api/torrent/:hash', requireAuth, async (req, res) => { + const { hash } = req.params + const deleteFiles = req.query.deleteFiles === 'true' + if (!hash) { res.status(400).json({ error: 'hash requis' }); return } + try { + const result = await dispatchRemove(hash, deleteFiles) + res.json(result) + } catch (err) { + logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`) + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) + } +}) + // ── Downloads list ───────────────────────────────────────────── app.get('/api/downloads', requireAuth, async (_req, res) => { try { @@ -920,10 +934,20 @@ server.listen(PORT, async () => { await autoOrganizeAll( () => dispatchList(category ?? 'fankai', infohashMap), seriesData, - (result) => { + async (result) => { if (result.done > 0 || result.errors > 0) { pushNotif({ ...result, at: new Date().toISOString() }) } + // Supprimer le torrent après move si option activée + const { organizeMode, deleteTorrentOnMove } = readSettings() + if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) { + try { + await dispatchRemove(result.hash, false) + logger.info('api', `Torrent "${result.name}" supprimé après move`) + } catch (err) { + logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`) + } + } } ) } catch (err) { diff --git a/server/settings.ts b/server/settings.ts index 31d2fca..ab2b10a 100644 --- a/server/settings.ts +++ b/server/settings.ts @@ -17,7 +17,7 @@ export interface Settings { const defaults: Settings = { mediaPath : '', completePath: '', - organizeMode: 'hardlink', + organizeMode : 'hardlink' | 'copy' | 'move', category : 'fankai', nfoSupport : false, autoImport : true, diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 18c5d8d..75270d5 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -56,6 +56,7 @@ export interface TorrentClientDriver { healthcheck : (config: Record) => Promise<{ online: boolean; version?: string }> add : (config: Record, url: string) => Promise list : (config: Record, category?: string) => Promise + remove : (config: Record, hash: string, deleteFiles?: boolean) => Promise } // ─── Registry ───────────────────────────────────────────────────────────────── @@ -238,7 +239,25 @@ export async function dispatchDownload(url: string): Promise<{ uuid: string; nam return results } -// ─── Dispatch list ───────────────────────────────────────────────────────────── +// ─── Dispatch remove ─────────────────────────────────────────────────────────── + +export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> { + const clients = loadClients() + let found = false + for (const client of clients) { + const driver = getDriver(client.type) + if (!driver) continue + try { + await driver.remove(client.config, hash, deleteFiles) + logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`) + found = true + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`) + } + } + return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' } +} // Map inversée : titre normalisé → infohash (pour matcher les torrents Synology) function buildTitleIndex(infohashMap: Record): Map { diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 2395d86..cde0568 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -118,6 +118,20 @@ const QB: TorrentClientDriver = { 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}` : ''})`) }, + + async remove(config, hash, deleteFiles = false) { + const sid = await qbLogin(config) + const form = new FormData() + form.append('hashes', hash) + form.append('deleteFiles', deleteFiles ? 'true' : 'false') + const res = await fetch(`${config.url}/api/v2/torrents/delete`, { + method : 'POST', + body : form, + headers: { Cookie: `SID=${sid}` }, + }) + if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`) + logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) + }, } export default QB \ No newline at end of file diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts index e7cba3d..1a1304f 100644 --- a/server/torrent-clients/rtorrent.ts +++ b/server/torrent-clients/rtorrent.ts @@ -236,6 +236,18 @@ const RT: TorrentClientDriver = { await rpcCall(config, method, args) logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) }, + + async remove(config, hash, deleteFiles = false) { + const h = hash.toUpperCase() + if (deleteFiles) { + // Supprimer les fichiers puis effacer + await rpcCall(config, 'd.delete_tied', [h]) + await rpcCall(config, 'd.erase', [h]) + } else { + await rpcCall(config, 'd.erase', [h]) + } + logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) + }, } export default RT \ No newline at end of file diff --git a/server/torrent-clients/synology-ds.ts b/server/torrent-clients/synology-ds.ts index bd03690..98c7473 100644 --- a/server/torrent-clients/synology-ds.ts +++ b/server/torrent-clients/synology-ds.ts @@ -151,6 +151,27 @@ const DS: TorrentClientDriver = { logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) }, + + async remove(config, hash, deleteFiles = false) { + const sid = await dsLogin(config) + // Synology utilise son ID interne — on cherche via la liste + const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid) + const tasks: any[] = data?.tasks ?? [] + // Matcher par hash si résolu, sinon par id + const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase()) + if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`) + const params = new URLSearchParams({ + api : 'SYNO.DownloadStation.Task', + version: '1', + method : 'delete', + id : found.id, + force_complete: deleteFiles ? 'false' : 'false', + _sid : sid, + }) + const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + logger.info('synology-ds', `Torrent ${found.id} supprimé`) + }, } export default DS \ No newline at end of file diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index c2273ed..da98598 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -136,6 +136,16 @@ const TR: TorrentClientDriver = { await trRequest(config, 'torrent-add', args) logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, + + async remove(config, hash, deleteFiles = false) { + // Transmission identifie les torrents par ID numérique — on cherche via la liste + const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] }) + const torrents: any[] = data.arguments?.torrents ?? [] + const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase()) + if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`) + await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles }) + logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) + }, } export default TR \ No newline at end of file diff --git a/server/torrent-clients/utorrent.ts b/server/torrent-clients/utorrent.ts index a345185..2541fc9 100644 --- a/server/torrent-clients/utorrent.ts +++ b/server/torrent-clients/utorrent.ts @@ -194,6 +194,15 @@ const UT: TorrentClientDriver = { logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, + + async remove(config, hash, deleteFiles = false) { + const s = await utGetSession(config) + await utRequest(config, { + action: deleteFiles ? 'removedata' : 'remove', + hash : hash.toUpperCase(), + }, s) + logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) + }, } export default UT \ No newline at end of file diff --git a/src/views/DownloadsView.vue b/src/views/DownloadsView.vue index b9ec283..dd526d9 100644 --- a/src/views/DownloadsView.vue +++ b/src/views/DownloadsView.vue @@ -284,6 +284,40 @@ > {{ importing[t.hash] ? '...' : 'Importer' }} + + +
+ + + +
+

Supprimer ce torrent ?

+ +
+ + +
+
+
+ @@ -441,28 +475,52 @@ function togglePolling() { onMounted(startPolling) onUnmounted(stopPolling) -async function importTorrent(torrent: any) { - importing.value[torrent.hash] = true +const confirmDelete = ref(null) +const deleting = ref>({}) +const deleteWithFiles = ref>({}) + +async function deleteTorrent(torrent: any) { + deleting.value[torrent.hash] = true try { - const res = await fetch('/api/organize', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const withFiles = deleteWithFiles.value[torrent.hash] ?? false + const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { + method : 'DELETE', credentials: 'include', - body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }), }) if (res.ok) { - toast(`${torrent.name} importé ✓`, 'success') + toast(`"${torrent.name}" supprimé ✓`, 'success') + confirmDelete.value = null await fetchTorrents() } else { - const { error } = await res.json() - toast(error ?? "Erreur lors de l'import", 'error') + toast('Erreur lors de la suppression', 'error') } } catch { toast('Impossible de contacter le serveur', 'error') } finally { - importing.value[torrent.hash] = false + deleting.value[torrent.hash] = false } } +importing.value[torrent.hash] = true +try { + const res = await fetch('/api/organize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }), + }) + if (res.ok) { + toast(`${torrent.name} importé ✓`, 'success') + await fetchTorrents() + } else { + const { error } = await res.json() + toast(error ?? "Erreur lors de l'import", 'error') + } +} catch { + toast('Impossible de contacter le serveur', 'error') +} finally { + importing.value[torrent.hash] = false +} +} async function importAll() { const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done') diff --git a/src/views/settings/MediaManagementView.vue b/src/views/settings/MediaManagementView.vue index ea4c7fc..c1f4c17 100644 --- a/src/views/settings/MediaManagementView.vue +++ b/src/views/settings/MediaManagementView.vue @@ -66,6 +66,15 @@ > Hardlink +