feat: ajout du bouton suppresion de torrent en cours

feat: ajout d'un bouton supprimé l'importation dans le menu d'importation d'une serie

feat: ajout de badge quand le fichier est introuvable

feat: suppression des series supprimé

feat: bouton tous télécharger sur les saisons et series
This commit is contained in:
MasutaYunikon committed 2026-04-14 19:05:11 +02:00
1 parent 5e79699ed9
commit d6f4433541
5 files changed
+487 -499

No files matched your search

+49 -9
View File
@@ -54,16 +54,17 @@ 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, deleteTorrentOnMove } = req.body
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode, deleteTorrentOnMove, autoUnimportMissing } = 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 (deleteTorrentOnMove !== undefined) patch.deleteTorrentOnMove = deleteTorrentOnMove
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
if (autoUnimportMissing !== undefined) patch.autoUnimportMissing = autoUnimportMissing
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
@@ -840,6 +841,43 @@ app.post('/api/rename-episode', requireAuth, async (req, res) => {
}
})
// ── Désimport série complète ───────────────────────────────────
app.delete('/api/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
let organized: Record<string, Record<string, any>> = {}
try { if (fs.existsSync(ORGANIZED_PATH)) organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.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 (err) { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
app.delete('/api/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
@@ -921,6 +959,7 @@ app.get('/api/organized-summary', requireAuth, async (_req, res) => {
}
const needsRename = expectedName !== currentName
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
episodes.push({
episode_id : ep.id,
episode_number: ep.episode_number,
@@ -931,6 +970,7 @@ app.get('/api/organized-summary', requireAuth, async (_req, res) => {
dest_path : orgEntry.dest_path,
torrent_hash : orgHash,
needs_rename : needsRename,
file_exists : fileExists,
})
}
}
+19 -2
View File
@@ -196,11 +196,28 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (result.added > 0 || removed > 0) {
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any
let autoRemoved = 0
if (autoUnimportMissing) {
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
const destPath = entry?.dest_path
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
}
if (result.added > 0 || removed > 0 || autoRemoved > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
}
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
return result
}
+87 -254
View File
@@ -7,10 +7,7 @@
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5">
<span
class="w-1.5 h-1.5 rounded-full transition-colors"
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
/>
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
</div>
</div>
@@ -19,15 +16,12 @@
{{ importingAll ? '...' : 'Importer tout' }}
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
</button>
<button @click="togglePolling" class="btn-secondary">
{{ polling ? 'Pause' : 'Reprendre' }}
</button>
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
</div>
</div>
@@ -39,39 +33,24 @@
</div>
</div>
<!-- Toolbar : recherche + tri + colonnes -->
<!-- Toolbar -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
<input v-model="search" type="text" placeholder="Rechercher..." class="settings-input pl-9 w-full py-1.5 text-sm" />
</div>
<!-- Tri -->
<div class="relative shrink-0" ref="sortRef">
<button
@click="sortOpen = !sortOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
>
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="sortOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
>
<button
v-for="s in sortOptions" :key="s.value"
@click="setSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
<button v-for="s in sortOptions" :key="s.value" @click="setSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
@@ -81,25 +60,15 @@
<!-- Colonnes -->
<div class="relative shrink-0" ref="colRef">
<button
@click="colOpen = !colOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="colOpen ? 'border-accent text-accent' : ''"
>
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span class="hidden md:inline text-xs">Colonnes</span>
</button>
<div
v-if="colOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl"
>
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
<label
v-for="col in columnOptions" :key="col.key"
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors"
>
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
<input type="checkbox" v-model="columns[col.key]" class="accent-accent" />
{{ col.label }}
</label>
@@ -110,53 +79,27 @@
<!-- Onglets + filtres -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
<button
@click="activeTab = 'active'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
<button @click="activeTab = 'active'" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
En cours
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ activeTorrents.length }}
</span>
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">{{ activeTorrents.length }}</span>
</button>
<button
@click="activeTab = 'done'"
class="px-4 py-1.5 text-xs rounded-md transition-colors"
:class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'"
>
<button @click="activeTab = 'done'" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
Terminés
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
{{ doneTorrents.length }}
</span>
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">{{ doneTorrents.length }}</span>
</button>
</div>
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Seeds uniquement</span>
<button
@click="seedingOnly = !seedingOnly"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="seedingOnly ? 'bg-green-500' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'"
/>
<button @click="seedingOnly = !seedingOnly" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-muted">Masquer importés</span>
<button
@click="hideImported = !hideImported"
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
:class="hideImported ? 'bg-accent' : 'bg-border'"
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
/>
<button @click="hideImported = !hideImported" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
</button>
</label>
</div>
@@ -165,12 +108,10 @@
<!-- Pas de clients -->
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
<p class="text-sm text-muted">Aucun client torrent configuré</p>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">
Configurer un client →
</RouterLink>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
</div>
<!-- Loading initial -->
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
@@ -178,28 +119,20 @@
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
</p>
<p class="text-xs text-muted">
{{ activeTab === 'active'
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
</p>
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
</div>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<div v-for="t in visibleTorrents" :key="t.hash" class="settings-card flex flex-col gap-3">
<!-- Nom + état + progression -->
<!-- Nom + état -->
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium truncate">{{ t.name }}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
{{ stateBadge(t.state).label }}
</span>
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">{{ stateBadge(t.state).label }}</span>
<span v-if="columns.client" class="text-xs text-muted">{{ t.client_name }}</span>
</div>
</div>
@@ -211,74 +144,55 @@
<!-- Barre de progression -->
<div class="h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress(t)}%` }"
/>
<div class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' : t.state === 'error' ? 'bg-red-500' : t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${clampedProgress(t)}%` }" />
</div>
<!-- Infos bas + actions -->
<!-- Infos + actions -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-xs text-muted">
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<template v-if="t.state === 'seeding'">
<span class="text-green-500">Complété</span>
<span v-if="columns.ratio" class="font-mono"
:class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">
R {{ t.ratio?.toFixed(2) ?? '0.00' }}
</span>
<span v-if="columns.ratio" class="font-mono" :class="t.ratio >= 1 ? 'text-green-400' : t.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ t.ratio?.toFixed(2) ?? '0.00' }}</span>
<span v-if="columns.uploaded">↑ {{ formatSize(t.uploaded ?? 0) }}</span>
<span v-if="columns.upspeed && t.upspeed > 0">{{ formatSpeed(t.upspeed) }}</span>
</template>
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<!-- Erreurs fichiers -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">
{{ t.errorFiles.length }} erreur{{ t.errorFiles.length > 1 ? 's' : '' }}
</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
<!-- Actions spécifiques aux terminés -->
<template v-if="t.state === 'seeding' || t.state === 'unknown'">
<!-- Erreurs -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ t.errorFiles.length }} erreur{{ t.errorFiles.length > 1 ? 's' : '' }}</span>
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-primary truncate">{{ e.file }}</p>
<p class="text-muted">{{ e.error }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Badge import -->
<span
class="text-xs px-2 py-0.5 rounded border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-border text-muted'"
>
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'Non importé' }}
</span>
<!-- Badge import -->
<span class="text-xs px-2 py-0.5 rounded border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' : t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` : t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` : 'Non importé' }}
</span>
<button
v-if="t.organizeState !== 'done'"
@click="importTorrent(t)"
:disabled="importing[t.hash]"
class="btn-secondary text-xs py-1"
>
{{ importing[t.hash] ? '...' : 'Importer' }}
</button>
<button v-if="t.organizeState !== 'done'" @click="importTorrent(t)" :disabled="importing[t.hash]" class="btn-secondary text-xs py-1">
{{ importing[t.hash] ? '...' : 'Importer' }}
</button>
</template>
<!-- Bouton supprimer -->
<!-- Bouton supprimer (tous états) -->
<div class="relative">
<button
@click="confirmDelete = confirmDelete === t.hash ? null : t.hash"
@@ -291,29 +205,19 @@
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
</button>
<!-- Popup 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>
<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">
{{ activeTab === 'active' ? 'Annuler ce téléchargement ?' : '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)"
:disabled="deleting[t.hash]"
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"
>
<button @click="deleteTorrent(t)" :disabled="deleting[t.hash]" 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"
>
<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>
@@ -360,14 +264,7 @@ const sortDir = ref<'asc' | 'desc'>('asc')
const sortRef = ref<HTMLElement | null>(null)
const colRef = ref<HTMLElement | null>(null)
const columns = ref({
client : true,
size : true,
ratio : true,
uploaded: true,
upspeed : true,
})
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
const columnOptions = [
{ key: 'client', label: 'Client' },
{ key: 'size', label: 'Taille / Téléchargé' },
@@ -375,7 +272,6 @@ const columnOptions = [
{ key: 'uploaded', label: 'Uploadé (total)' },
{ key: 'upspeed', label: 'Vitesse upload' },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
@@ -393,24 +289,19 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
if (activeTab.value === 'done') {
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
}
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
@@ -424,10 +315,8 @@ const visibleTorrents = computed(() => {
}
})
}
return list
})
const stats = computed(() => [
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
@@ -437,45 +326,30 @@ const stats = computed(() => [
// ─── Helpers ──────────────────────────────────────────────────
function setSort(val: typeof activeSort.value) {
if (activeSort.value === val) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
activeSort.value = val
sortDir.value = 'asc'
}
activeSort.value === val ? (sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc') : (activeSort.value = val, sortDir.value = 'asc')
sortOpen.value = false
}
function clampedProgress(t: any): number {
return Math.min(100, Math.max(0, t.progress ?? 0))
}
function clampedProgress(t: any) { return Math.min(100, Math.max(0, t.progress ?? 0)) }
function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = {
return ({
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
}
return map[state] ?? map.unknown
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
}
function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return '0 B'
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
return `${s}s`
@@ -489,95 +363,54 @@ async function fetchTorrents() {
if (!res.ok) return
torrents.value = await res.json()
noClients.value = false
} catch {
} finally {
loading.value = false
}
} catch {} finally { loading.value = false }
}
function startPolling() {
fetchTorrents()
pollInterval = setInterval(fetchTorrents, 60_000)
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
}
function togglePolling() {
polling.value = !polling.value
polling.value ? startPolling() : stopPolling()
}
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
async function importTorrent(torrent: any) {
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 }),
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
}
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')
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
importingAll.value = true
let done = 0, errors = 0
for (const t of toImport) {
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
})
res.ok ? done++ : errors++
} catch { errors++ }
}
importingAll.value = false
await fetchTorrents()
errors === 0
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
}
async function deleteTorrent(torrent: any) {
deleting.value[torrent.hash] = true
try {
const withFiles = deleteWithFiles.value[torrent.hash] ?? false
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, {
method : 'DELETE',
credentials: 'include',
})
if (res.ok) {
toast(`"${torrent.name}" supprimé ✓`, 'success')
confirmDelete.value = null
await fetchTorrents()
} else {
toast('Erreur lors de la suppression', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
deleting.value[torrent.hash] = false
}
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Erreur lors de la suppression', 'error')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { deleting.value[torrent.hash] = false }
}
onMounted(startPolling)
+127 -14
View File
@@ -43,7 +43,7 @@
Séries
</RouterLink>
<!-- Titre + bouton import manuel -->
<!-- Titre + boutons -->
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
<button
@@ -57,6 +57,25 @@
</svg>
Import manuel
</button>
<div v-if="Object.keys(organizedByEpisode).length > 0" class="relative" @click.stop>
<button
@click="unimportMenuOpen = !unimportMenuOpen"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-500/40 transition shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>
Désimporter
</button>
<div v-if="unimportMenuOpen" class="absolute top-full left-0 mt-1 bg-card border border-border rounded-xl p-1 z-20 w-48 shadow-xl flex flex-col gap-0.5">
<button @click="unimportSerie(false); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
Retirer de la bibliothèque
</button>
<button @click="unimportSerie(true); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
Supprimer les fichiers
</button>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -78,6 +97,21 @@
<!-- Boutons intégrale -->
<div class="flex flex-wrap gap-2 mt-1">
<!-- Bouton Tout télécharger si pas de torrent intégrale mais des torrents par épisode/saison -->
<button
v-if="data.torrents_integrale.length === 0 && hasDownloadableTorrents"
@click="downloadAll"
:disabled="downloadingAll"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
>
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
<path v-if="!downloadingAll" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline v-if="!downloadingAll" points="7 10 12 15 17 10"/><line v-if="!downloadingAll" x1="12" y1="15" x2="12" y2="3"/>
<path v-else d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
</svg>
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
</button>
<button
v-for="(t, i) in data.torrents_integrale"
:key="i"
@@ -153,18 +187,32 @@
</div>
</div>
<!-- Bouton pack saison -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
</button>
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Tout télécharger (épisodes individuels sans pack saison) -->
<button
v-if="!season.torrent && seasonHasDownloadable(season)"
@click="downloadSeason(season)"
:disabled="downloadingSeason[season.id]"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
>
<EpStateIcon :state="downloadingSeason[season.id] ? 'loading' : 'idle'" :size="12" />
{{ downloadingSeason[season.id] ? 'Envoi…' : 'Tout télécharger' }}
</button>
<!-- Bouton pack saison -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
</button>
</div>
</div>
<!-- Progression saison -->
@@ -370,6 +418,9 @@ const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
const epMenuOpen = ref<number | null>(null)
const epActionLoading = ref<Record<number, boolean>>({})
const unimportMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
@@ -514,6 +565,68 @@ function formatDuration(seconds: number): string {
return `${m} min`
}
async function unimportSerie(deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
await fetchOrganized()
load()
} catch {
toast('Impossible de contacter le serveur', 'error')
}
}
// ── Tout télécharger ─────────────────────────────────────────
// Vérifie si la série a des torrents par épisode téléchargeables (sans intégrale)
const hasDownloadableTorrents = computed(() => {
if (!data.value) return false
return data.value.seasons.some((season: any) => seasonHasDownloadable(season))
})
function seasonHasDownloadable(season: any): boolean {
return season.episodes.some((ep: any) =>
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent)
)
}
async function downloadAll() {
if (!data.value) return
downloadingAll.value = true
let sent = 0
for (const season of data.value.seasons) {
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent)) continue
try {
const result = await store.download(ep.torrent.torrent_url, ep.torrent.magnet)
if (result.success) { addDownloaded(`ep-${ep.id}`); sent++ }
} catch {}
}
}
downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger', 'success')
}
async function downloadSeason(season: any) {
downloadingSeason.value[season.id] = true
let sent = 0
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent)) continue
try {
const result = await store.download(ep.torrent.torrent_url, ep.torrent.magnet)
if (result.success) { addDownloaded(`ep-${ep.id}`); sent++ }
} catch {}
}
downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
}
// ── Rename / Désimport épisode ────────────────────────────────
// Calcule le nom attendu selon les settings actuels
@@ -591,7 +704,7 @@ onMounted(() => {
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
// Fermer le menu épisode au clic extérieur
document.addEventListener('click', () => { epMenuOpen.value = null })
document.addEventListener('click', () => { epMenuOpen.value = null; unimportMenuOpen.value = false })
})
onUnmounted(() => {
+205 -220
View File
@@ -1,261 +1,246 @@
<template>
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Media Management</h2>
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
</div>
<!-- Dossiers -->
<div class="settings-card flex flex-col gap-5">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
<p class="text-xs text-muted mt-0.5">
Visualisez et renommez vos fichiers importés selon le mode actuel
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
</span>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
<div class="flex items-center gap-2">
<button @click="load" :disabled="loading" class="btn-secondary">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
Actualiser
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
@click="form.organizeMode = 'hardlink'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'hardlink'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Hardlink
</button>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
:disabled="renamingAll"
class="btn-primary"
@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'"
>
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
Copier
</button>
<button
@click="form.organizeMode = 'move'"
class="px-4 py-2 text-xs rounded-lg border transition-colors"
:class="form.organizeMode === 'move'
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
Déplacer
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">Aucune série importée</p>
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
</div>
<!-- Résultat rename all -->
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
<p class="text-xs text-muted">
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
</div>
<!-- Liste séries -->
<div v-else class="flex flex-col gap-3">
<div
v-for="serie in filteredSeries"
:key="serie.serie_id"
class="settings-card flex flex-col gap-0"
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
v-model="form.nfoSupport"
label="NFO / Métadonnées"
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."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si fichier manquant"
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
/>
</div>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span
v-if="scanResult"
class="text-xs"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'"
>
<!-- Header série -->
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2.5">
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span v-if="serie.needs_rename > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ serie.needs_rename }} à renommer
</span>
<span v-else class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
✓ À jour
</span>
</div>
<button
v-if="serie.needs_rename > 0"
@click="renameAll(serie.serie_id)"
:disabled="renamingAll"
class="text-xs text-accent hover:underline"
>
Tout renommer
</button>
</div>
<!-- Liste épisodes -->
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
<div
v-for="ep in serie.episodes"
:key="ep.episode_id"
class="flex items-center gap-3 py-2.5"
>
<!-- Numéro -->
<span class="text-xs text-muted font-mono shrink-0 w-14">
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
</span>
<!-- Noms -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
<template v-if="ep.needs_rename">
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<span v-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button
v-else
@click="renameSingle(ep)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
</button>
<button
@click="unimportEp(ep, false)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Désimporter
</button>
<button
@click="unimportEp(ep, true)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
const loading = ref(true)
const renamingAll = ref(false)
const series = ref<any[]>([])
const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set())
const renamingEp = ref<Record<number, boolean>>({})
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false)
const saving = ref(false)
const scanning = ref(false)
const isDocker = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const form = ref({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink' as 'hardlink' | 'copy' | 'move',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove : false,
autoUnimportMissing : false,
})
const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false,
field: 'completePath',
currentPath: '/',
})
// FIX : détection path non configuré (vide ou racine /)
function isRootPath(p: string): boolean {
return !p || p === '/'
}
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
// ─── Actions ──────────────────────────────────────────────────
async function load() {
loading.value = true
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
}
function onPickerSelect(path: string) {
form.value[picker.value.field] = path
picker.value.open = false
}
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
})
async function save() {
saving.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(form.value),
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
loading.value = false
saving.value = false
}
}
function toggleSerie(id: number) {
if (collapsed.value.has(id)) collapsed.value.delete(id)
else collapsed.value.add(id)
// Forcer la réactivité
collapsed.value = new Set(collapsed.value)
}
async function renameAll(serieId?: number) {
renamingAll.value = true
renameResult.value = null
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/rename-all', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers analysés — ${data.added} ajoutés`
: `${data.found} fichiers analysés — rien de nouveau`,
'success'
)
} else {
toast("Erreur lors de l'analyse", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.value = false
scanning.value = false
}
}
async function renameSingle(ep: any) {
renamingEp.value[ep.episode_id] = true
try {
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingEp.value[ep.episode_id] = false
}
}
onMounted(load)
</script>