feat: ajout de la possibilité de renommer des imports dans le bon format

This commit is contained in:
MasutaYunikon committed 2026-04-14 17:29:01 +02:00
1 parent 788878ca6c
commit 699c130edd
4 files changed
+641 -2

No files matched your search

+247 -2
View File
@@ -428,7 +428,7 @@ app.post('/api/download', requireAuth, async (req, res) => {
})
app.delete('/api/torrent/:hash', requireAuth, async (req, res) => {
const hash = req.params.hash as string
const { hash } = req.params
const deleteFiles = req.query.deleteFiles === 'true'
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
try {
@@ -762,7 +762,252 @@ app.get('/api/organized/:serieId', requireAuth, async (req, res) => {
}
})
// ── Debug ──────────────────────────────────────────────────────
// ── Rename épisode ─────────────────────────────────────────────
// Renomme un fichier déjà importé selon le mode actuel (nfoSupport)
app.post('/api/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
const { nfoSupport } = readSettings()
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(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
// Trouver l'épisode dans les données
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (ep.id === Number(episode_id)) { foundEp = { ep, season }; break }
}
if (foundEp) break
}
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
const { ep, season } = foundEp
// Trouver l'entrée dans organized.json
const hash = torrent_hash?.toLowerCase() ?? 'manual'
const orgEntry = organized[hash]?.[String(episode_id)]
?? organized['manual']?.[String(episode_id)]
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
// Calculer le nouveau nom selon le mode actuel
const srcExt = path.extname(orgEntry.dest_filename)
let newName: string
if (nfoSupport) {
newName = ep.nfo_filename
? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
: orgEntry.dest_filename
} else {
newName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
: orgEntry.dest_filename
}
if (newName === orgEntry.dest_filename) {
res.json({ ok: true, renamed: false, message: 'Nom déjà correct' })
return
}
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath)
// Mettre à jour organized.json
const entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
organized[entryHash][String(episode_id)] = {
...orgEntry,
dest_filename: newName,
dest_path : newPath,
at : new Date().toISOString(),
}
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Désimport épisode ──────────────────────────────────────────
app.delete('/api/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const { serieId, episodeId } = req.params
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 {}
// Chercher l'entrée dans tous les hash
let foundHash: string | null = null
let foundEntry: any = null
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) { foundHash = hash; foundEntry = episodes[episodeId]; break }
}
if (!foundHash || !foundEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
try {
if (deleteFile && foundEntry.dest_path && fs.existsSync(foundEntry.dest_path)) {
fs.unlinkSync(foundEntry.dest_path)
logger.info('api', `Désimport + suppression fichier : "${foundEntry.dest_path}"`)
}
delete organized[foundHash][episodeId]
if (Object.keys(organized[foundHash]).length === 0) delete organized[foundHash]
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('api', `Désimport ep ${episodeId} (série ${serieId})`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Désimport échoué : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Récap global des imports ───────────────────────────────────
app.get('/api/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
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 seriesData = await loadEnrichedSeriesData()
const result: any[] = []
for (const sd of seriesData) {
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const episodes: any[] = []
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
// Chercher l'entrée dans organized.json
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry) continue
// Calculer le nom attendu selon nfoSupport actuel
const srcExt = path.extname(orgEntry.dest_filename)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename
? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
: orgEntry.dest_filename
} else {
expectedName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
: orgEntry.dest_filename
}
const needsRename = expectedName !== orgEntry.dest_filename
episodes.push({
episode_id : ep.id,
episode_number: ep.episode_number,
season_number : season.season_number,
title : ep.title,
current_name : orgEntry.dest_filename,
expected_name : expectedName,
dest_path : orgEntry.dest_path,
torrent_hash : orgHash,
needs_rename : needsRename,
})
}
}
if (episodes.length > 0) {
const needsRenameCount = episodes.filter(e => e.needs_rename).length
result.push({
serie_id : sd.id,
serie_title : rawTitle,
serie_title_clean: serieTitle,
total : episodes.length,
needs_rename : needsRenameCount,
episodes,
})
}
}
res.json({ series: result, nfo_support: nfoSupport })
} catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Rename en masse ────────────────────────────────────────────
app.post('/api/rename-all', requireAuth, async (req, res) => {
const { serie_id } = req.body // optionnel — si absent, rename toutes les séries
const { nfoSupport } = readSettings()
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 seriesData = await loadEnrichedSeriesData()
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
if (serie_id && sd.id !== Number(serie_id)) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry || !orgHash) continue
const srcExt = path.extname(orgEntry.dest_filename)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename
? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
: orgEntry.dest_filename
} else {
expectedName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
: orgEntry.dest_filename
}
if (expectedName === orgEntry.dest_filename) continue
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) { errors.push({ episode_id: ep.id, error: 'Fichier introuvable' }); continue }
if (fs.existsSync(newPath)) { errors.push({ episode_id: ep.id, error: `Fichier existant : ${expectedName}` }); continue }
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = {
...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString()
}
done.push(ep.id)
logger.info('api', `Rename masse : "${orgEntry.dest_filename}" → "${expectedName}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
errors.push({ episode_id: ep.id, error: msg })
}
}
}
}
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
res.json({ ok: true, done: done.length, errors })
})
app.get('/api/debug/stats', requireAuth, (req, res) => {
const { devMode } = readSettings()
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
+5
View File
@@ -63,6 +63,11 @@ const router = createRouter({
name: 'settings-media',
component: () => import('@/views/settings/MediaManagementView.vue'),
},
{
path: 'import-management',
name: 'setting-import',
component: () => import('@/views/settings/ImportManagementView.vue'),
},
{
path: 'catalogue',
name: 'settings-catalogue',
+128
View File
@@ -228,6 +228,58 @@
<EpStateIcon :state="epState(ep)" :size="14" />
</button>
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename disponible -->
<span
v-if="epNeedsRename(ep)"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 cursor-default"
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>
Rename
</span>
<!-- Menu actions -->
<div class="relative" @click.stop>
<button
@click="epMenuOpen = epMenuOpen === ep.id ? null : ep.id"
class="w-6 h-6 flex items-center justify-center rounded text-muted hover:text-primary transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<div
v-if="epMenuOpen === ep.id"
class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-44 shadow-xl flex flex-col gap-0.5"
>
<button
v-if="epNeedsRename(ep)"
@click="renameEpisode(ep, season); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Renommer
</button>
<button
@click="unimportEpisode(ep, season, false); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors"
>
<svg width="11" height="11" 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>
<button
@click="unimportEpisode(ep, season, true); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
Supprimer le fichier
</button>
</div>
</div>
</template>
</div>
<!-- Barre progression épisode -->
@@ -316,6 +368,8 @@ const downloaded = ref<string[]>([])
const manualImportOpen = ref(false)
const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
const epMenuOpen = ref<number | null>(null)
const epActionLoading = ref<Record<number, boolean>>({})
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
@@ -460,14 +514,88 @@ function formatDuration(seconds: number): string {
return `${m} min`
}
// ── Rename / Désimport épisode ────────────────────────────────
// Calcule le nom attendu selon les settings actuels
function epExpectedName(ep: any): string {
const entry = organizedByEpisode.value[String(ep.id)]
if (!entry) return ''
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
// On ne connaît pas nfoSupport ici — on utilise ce qui est dispo
// Si nfo_filename existe dans ep, c'est le nom attendu en mode nfo
// On compare avec le nom actuel pour détecter si un rename est possible
if (ep.nfo_filename) {
const nfoName = ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
return nfoName
}
if (ep.formatted_name?.trim()) {
return ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
}
return entry.dest_filename
}
function epNeedsRename(ep: any): boolean {
const entry = organizedByEpisode.value[String(ep.id)]
if (!entry) return false
const expected = epExpectedName(ep)
return !!expected && expected !== entry.dest_filename
}
async function renameEpisode(ep: any, season: any) {
epActionLoading.value[ep.id] = true
try {
// Trouver le hash du torrent pour cet épisode
const torrentHash = ep.paths?.[0]?.infohash ?? null
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur rename', 'error'); return }
if (data.renamed) {
toast(`Renommé : ${data.new_name}`, 'success')
await fetchOrganized()
} else {
toast(data.message ?? 'Nom déjà correct', 'success')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
epActionLoading.value[ep.id] = false
}
}
async function unimportEpisode(ep: any, season: any, deleteFile: boolean) {
epActionLoading.value[ep.id] = true
try {
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
await fetchOrganized()
load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
epActionLoading.value[ep.id] = false
}
}
onMounted(() => {
load()
fetchSettings()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
// Fermer le menu épisode au clic extérieur
document.addEventListener('click', () => { epMenuOpen.value = null })
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
document.removeEventListener('click', () => { epMenuOpen.value = null })
})
</script>
+261
View File
@@ -0,0 +1,261 @@
<template>
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
<!-- 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>
</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
</button>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
:disabled="renamingAll"
class="btn-primary"
>
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
</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>
<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"
>
<!-- 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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
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)
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
async function load() {
loading.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))
} finally {
loading.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
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()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingAll.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>