feat: désimport saison/série avec modal, refonte UX épisodes, fix sélection fichier qBit/Transmission

- SerieSeasonCard: remplace le menu burger épisode par corbeille directe, badge Rename cliquable,
  bouton synopsis (ℹ) avec affichage inline, bouton téléchargement masqué si épisode importé
- SerieSeasonCard: modal désimport épisode (avec checkbox suppression fichier) via Teleport
- SerieSeasonCard: bouton corbeille saison + modal désimport saison (avec checkbox suppression fichier)
- SerieView: bouton Désimporter série → modal (remplace dropdown), gère l'emit unimportSeason depuis card
- import.ts: nouvel endpoint DELETE /organized/:serieId/seasons/:seasonId
- qbittorrent: fallback hash pour .torrent sans magnet (snapshot → poll → diff → priorité fichier)
- transmission: fix sélection fichier (trApplyFilePriority async, fileStats→files, files-wanted via torrent-set)
This commit is contained in:
MasutaYunikon committed 2026-04-28 20:03:47 +02:00
1 parent eb4cb30efd
commit 3fb4119c79
5 files changed
+303 -99

No files matched your search

+30
View File
@@ -239,6 +239,36 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
res.json({ ok: true, removed, errors })
})
// ── Désimport saison ───────────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serieId))
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.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 { errors.push(entry.dest_path) }
}
delete organized[hash][epId]
removed++
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
saveOrganizedJson(organized)
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
+48 -19
View File
@@ -221,27 +221,33 @@ const QB: TorrentClientDriver = {
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
if (options?.file_index != null && hash) {
const exists = await qbTorrentExists(config, sid, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
if (options?.file_index != null) {
if (hash) {
const exists = await qbTorrentExists(config, sid, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
// Nouveau torrent → ajout actif avec stopCondition=MetadataReceived
// qBit fetch les métadonnées normalement via DHT/peers,
// puis se met en pause automatiquement dès qu'elles sont disponibles.
// On set les priorités sur la pause, puis on reprend. Zéro data indésirable.
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
// Ajout du torrent (stopCondition=MetadataReceived si hash connu, qBit ≥ 4.6)
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('stopCondition', 'MetadataReceived') // qBit ≥ 4.6
if (hash) form.append('stopCondition', 'MetadataReceived')
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
@@ -249,10 +255,33 @@ const QB: TorrentClientDriver = {
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, sélection fichier ${options.file_index} en attente)`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (stopCondition=MetadataReceived, sélection fichier ${options.file_index} en attente)`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
// Hash inconnu (.torrent sans magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const s = await qbLogin(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, false)
return
} catch {}
}
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
})().catch(() => {})
}
return
}
+55 -23
View File
@@ -52,6 +52,44 @@ async function trRequest(
return { ...data, sessionId }
}
async function trGetIdByHash(config: Record<string, string | number>, hash: string): Promise<number | null> {
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents = data.arguments?.torrents ?? []
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
/**
* Attend que les métadonnées soient disponibles (polling 500 ms),
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const id = await trGetIdByHash(config, hash)
if (id == null) continue
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
const files = data.arguments?.torrents?.[0]?.files
if (!Array.isArray(files) || files.length === 0) continue
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
await trRequest(config, 'torrent-set', {
ids : [id],
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
}
const TR: TorrentClientDriver = {
definition: {
id : 'transmission',
@@ -96,7 +134,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'fileStats', // progression par fichier (bytesCompleted + length)
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
@@ -120,42 +158,36 @@ const TR: TorrentClientDriver = {
eta : t.eta ?? -1,
save_path : t.downloadDir,
category : t.labels?.[0] ?? '',
files : Array.isArray(t.fileStats) && t.fileStats.length > 0
? t.fileStats.map((fs: any, i: number) => ({
files : Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
progress: fs.length > 0 ? Math.round((fs.bytesCompleted / fs.length) * 100) : 0,
progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0,
}))
: undefined,
} satisfies TorrentInfo))
},
async add(config, url, options?: DownloadOptions) {
const args: Record<string, unknown> = { filename: url }
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
const args: Record<string, unknown> = { filename: url }
if (config.savePath) args['download-dir'] = String(config.savePath)
if (config.category) args['labels'] = [String(config.category)]
// Transmission applique files-wanted après récupération des métadonnées,
// sans démarrer les autres fichiers entre-temps — natif et fiable.
if (options?.file_index != null) {
args['files-wanted'] = [options.file_index]
}
const res = await trRequest(config, 'torrent-add', args)
// Torrent déjà présent → Transmission renvoie torrent-duplicate
// On s'assure quand même que le fichier voulu est activé
if (options?.file_index != null && res.arguments?.['torrent-duplicate']) {
const dup = res.arguments['torrent-duplicate']
try {
await trRequest(config, 'torrent-set', {
ids : [dup.id],
'files-wanted': [options.file_index],
})
logger.info('transmission', `Torrent ${dup.hashString?.slice(0, 8)}… déjà présent, fichier ${options.file_index} activé`)
} catch (err) {
logger.warn('transmission', `Activation fichier sur torrent existant échouée : ${err instanceof Error ? err.message : err}`)
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
} else {
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
+120 -35
View File
@@ -31,6 +31,15 @@
<!-- Boutons saison -->
<div class="flex items-center gap-2">
<!-- Corbeille saison -->
<button
v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
title="Désimporter la saison"
>
<Trash2 :size="14" />
</button>
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
<template v-if="season.torrents.length === 0 && hasDownloadable">
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
@@ -169,8 +178,8 @@
</div>
</div>
<!-- Bouton état épisode (dropdown si plusieurs options) -->
<div class="relative shrink-0" @click.stop>
<!-- Bouton téléchargement épisode (uniquement si non organisé) -->
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
<button
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="epBtnClass(ep)"
@@ -180,7 +189,7 @@
<component :is="epStateIcon(ep)" />
</button>
<!-- Dropdown quand plusieurs options de torrent -->
<div v-if="epOptionsOpen === ep.id && !ep.organized && ep.torrents && ep.torrents.length > 1"
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in ep.torrents"
@@ -198,34 +207,33 @@
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<span
<!-- Bouton synopsis -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
class="w-6 h-6 flex items-center justify-center rounded transition-colors"
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
title="Voir le synopsis"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
</button>
<!-- Badge rename cliquable -->
<button
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"
@click.stop="emit('renameEpisode', ep, season)"
:disabled="epActionLoading[ep.id]"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>Rename</span>
<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="emit('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="emit('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="emit('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>
>Rename</button>
<!-- Corbeille épisode -->
<button
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
title="Désimporter l'épisode"
>
<Trash2 :size="12" />
</button>
</template>
</div>
@@ -235,14 +243,59 @@
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
</div>
<!-- Synopsis -->
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ ep.plot }}</p>
</div>
</div>
</div>
</div>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
</label>
<div class="flex gap-2 justify-end">
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
@@ -264,11 +317,17 @@ const emit = defineEmits<{
downloadSeason : [season: any, packHash?: string]
renameEpisode : [ep: any, season: any]
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
unimportSeason : [season: any, deleteFiles: boolean]
}>()
const epMenuOpen = ref<number | null>(null)
const epOptionsOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
const epOptionsOpen = ref<number | null>(null)
const plotOpen = ref<number | null>(null)
const seasonMenuOpen = ref(false)
const unimportModal = ref(false)
const deleteFileOnUnimport = ref(false)
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
const unimportSeasonModal = ref(false)
const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 12 })
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
const checkIcon = h(Check, { size: 12 })
@@ -505,9 +564,35 @@ function formatDuration(seconds: number): string {
return `${m} min`
}
function togglePlot(epId: number) {
plotOpen.value = plotOpen.value === epId ? null : epId
}
function openUnimportModal(ep: any, season: any) {
unimportEpTarget.value = { ep, season }
deleteFileOnUnimport.value = false
unimportModal.value = true
}
function confirmUnimport() {
if (!unimportEpTarget.value) return
emit('unimportEpisode', unimportEpTarget.value.ep, unimportEpTarget.value.season, deleteFileOnUnimport.value)
unimportModal.value = false
unimportEpTarget.value = null
}
function openUnimportSeasonModal() {
deleteSeasonFiles.value = false
unimportSeasonModal.value = true
}
function confirmUnimportSeason() {
emit('unimportSeason', props.season, deleteSeasonFiles.value)
unimportSeasonModal.value = false
}
function closeAllMenus() {
epMenuOpen.value = null
epOptionsOpen.value = null
epOptionsOpen.value = null
seasonMenuOpen.value = false
}
+50 -22
View File
@@ -35,26 +35,17 @@
Import manuel
</button>
<!-- Désimporter -->
<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 font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
>
<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>
<!-- Désimporter série -->
<button
v-if="Object.keys(organizedByEpisode).length > 0"
@click="openUnimportSerieModal"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
>
<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>
<!-- Droite : téléchargements -->
@@ -113,10 +104,33 @@
@download-season="(s, h) => downloadSeason(s, h)"
@rename-episode="(ep, s) => renameEpisode(ep, s)"
@unimport-episode="(ep, s, del) => unimportEpisode(ep, s, del)"
@unimport-season="(s, del) => unimportSeason(s, del)"
/>
</div>
</template>
<!-- Modal désimport série -->
<Teleport to="body">
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
<div>
<p class="text-sm font-semibold text-primary">Désimporter la série</p>
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded border-border accent-accent" />
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
</label>
<div class="flex items-center justify-end gap-2">
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
{{ deleteSerieFiles ? 'Désimporter et supprimer' : 'Désimporter' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
@@ -154,7 +168,8 @@ const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
const nfoSupport = ref(false)
const epActionLoading = ref<Record<number, boolean>>({})
const unimportMenuOpen = ref(false)
const unimportSerieModal = ref(false)
const deleteSerieFiles = ref(false)
const downloadMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
@@ -436,6 +451,19 @@ function toggleSeason(id: number) {
else collapsedSeasons.value.add(id)
}
function openUnimportSerieModal() { deleteSerieFiles.value = false; unimportSerieModal.value = true }
async function confirmUnimportSerie() { unimportSerieModal.value = false; await unimportSerie(deleteSerieFiles.value) }
async function unimportSeason(season: any, deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
}
async function unimportSerie(deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
@@ -473,7 +501,7 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
finally { epActionLoading.value[ep.id] = false }
}
const closeMenus = () => { unimportMenuOpen.value = false; downloadMenuOpen.value = false }
const closeMenus = () => { downloadMenuOpen.value = false }
onMounted(() => {
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()