feat: ajout du bouton copie

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
This commit is contained in:
MasutaYunikon committed 2026-04-11 12:51:31 +02:00
1 parent 73b401f3e7
commit 392f736925
10 files changed
+211 -27

No files matched your search

+34 -10
View File
@@ -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<string, any> = {}
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) {
+1 -1
View File
@@ -17,7 +17,7 @@ export interface Settings {
const defaults: Settings = {
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
organizeMode : 'hardlink' | 'copy' | 'move',
category : 'fankai',
nfoSupport : false,
autoImport : true,
+20 -1
View File
@@ -56,6 +56,7 @@ export interface TorrentClientDriver {
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
}
// ─── 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<string, string>): Map<string, string> {
+14
View File
@@ -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
+12
View File
@@ -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
+21
View File
@@ -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
+10
View File
@@ -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
+9
View File
@@ -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
+68 -10
View File
@@ -284,6 +284,40 @@
>
{{ importing[t.hash] ? '...' : 'Importer' }}
</button>
<!-- Bouton supprimer -->
<div class="relative group/del">
<button
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
:disabled="deleting[t.hash]"
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
<!-- Confirmation -->
<div
v-if="confirmDelete === t.hash"
class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl"
>
<p class="text-xs text-primary font-medium mb-2">Supprimer ce torrent ?</p>
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
<input type="checkbox" v-model="deleteWithFiles[t.hash]" class="accent-red-500" />
Supprimer aussi les fichiers
</label>
<div class="flex gap-1.5">
<button @click="deleteTorrent(t)" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
{{ deleting[t.hash] ? '...' : 'Confirmer' }}
</button>
<button @click="confirmDelete = null" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
Annuler
</button>
</div>
</div>
</div>
</div>
</div>
@@ -441,28 +475,52 @@ function togglePolling() {
onMounted(startPolling)
onUnmounted(stopPolling)
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
const confirmDelete = ref<string | null>(null)
const deleting = ref<Record<string, boolean>>({})
const deleteWithFiles = ref<Record<string, boolean>>({})
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')
+22 -5
View File
@@ -66,6 +66,15 @@
>
Hardlink
</button>
<button
@click="form.organizeMode = 'copy'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'copy'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Copier
</button>
<button
@click="form.organizeMode = 'move'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
@@ -96,6 +105,13 @@
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
/>
</div>
<!-- Actions -->
@@ -140,11 +156,12 @@ const isDocker = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const form = ref({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
autoImport : true,
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove: false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({