feat: ajout de la possibilité de renommer des imports dans le bon format
This commit is contained in:
1 parent
788878ca6c
commit
699c130edd
4 files changed
+641
-2
No files matched your search
+247
-2
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user