feat: actions de masse sur les séries et fiabilisation des imports et correction protection admin
This commit is contained in:
1 parent
7479ddf8eb
commit
7f06dd654a
19 files changed
+637
-258
No files matched your search
@@ -75,6 +75,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sélection multiple -->
|
||||
<button
|
||||
v-if="selectable"
|
||||
@click="emit('update:selecting', !selecting)"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
|
||||
:class="selecting ? 'border-accent text-accent' : ''"
|
||||
title="Sélectionner plusieurs séries"
|
||||
>
|
||||
<ListChecks :size="15" />
|
||||
<span class="hidden md:inline text-xs">Sélection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
@@ -121,7 +133,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown } from 'lucide-vue-next'
|
||||
import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
@@ -136,6 +148,8 @@ defineProps<{
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -143,6 +157,7 @@ const emit = defineEmits<{
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface Serie {
|
||||
status: string | null
|
||||
torrent_count: number
|
||||
has_torrents: boolean
|
||||
download_state: 'none' | 'downloading' | 'partial' | 'complete'
|
||||
in_client: boolean
|
||||
has_files: boolean
|
||||
rss_synced: boolean
|
||||
}
|
||||
|
||||
export interface SerieDetail {
|
||||
|
||||
+54
-29
@@ -405,6 +405,16 @@ function integraleGroupLabels(): string[] {
|
||||
}
|
||||
|
||||
// Épisodes couverts par une intégrale donnée (hash commun)
|
||||
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
|
||||
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
|
||||
const organizedEpisodeIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const season of data.value?.seasons ?? [])
|
||||
for (const ep of season.episodes)
|
||||
if (ep.organized) ids.add(ep.id)
|
||||
return ids
|
||||
})
|
||||
|
||||
function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
|
||||
const covered = new Set<number>()
|
||||
const t = data.value?.torrents_integrale[integraleIndex]
|
||||
@@ -503,6 +513,8 @@ async function fetchActiveDownloads() {
|
||||
const list: any[] = await res.json()
|
||||
|
||||
const isFirstPoll = _prevTorrentStates.size === 0
|
||||
// Un seul import par torrent, même si plusieurs de ses fichiers viennent de se terminer
|
||||
const toOrganize = new Map<string, any>()
|
||||
|
||||
for (const t of list) {
|
||||
const prevState = _prevTorrentStates.get(t.hash)
|
||||
@@ -515,7 +527,7 @@ async function fetchActiveDownloads() {
|
||||
_prevFileProgresses.set(key, f.progress)
|
||||
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
|
||||
_recentlyOrganized.add(key)
|
||||
triggerOrganize(t)
|
||||
toOrganize.set(t.hash, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,12 +535,14 @@ async function fetchActiveDownloads() {
|
||||
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
|
||||
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
|
||||
_recentlyOrganized.add(t.hash)
|
||||
triggerOrganize(t)
|
||||
toOrganize.set(t.hash, t)
|
||||
}
|
||||
|
||||
_prevTorrentStates.set(t.hash, t.state)
|
||||
}
|
||||
|
||||
for (const t of toOrganize.values()) triggerOrganize(t)
|
||||
|
||||
activeTorrents.value = list.map(t => ({
|
||||
hash : t.hash,
|
||||
progress : t.progress ?? 0,
|
||||
@@ -582,6 +596,12 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
const result: DlItem[] = []
|
||||
const covered = new Set<number>()
|
||||
|
||||
// Une autre version déjà envoyée couvre déjà ses épisodes
|
||||
data.value.torrents_integrale.forEach((t: any, i: number) => {
|
||||
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
|
||||
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
|
||||
})
|
||||
|
||||
// ── 1. Intégrale(s) ───────────────────────────────────────────
|
||||
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
|
||||
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
|
||||
@@ -589,15 +609,23 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
|
||||
for (const { t, i } of integrales) {
|
||||
if (!t) continue
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
// Marquer les épisodes couverts par CETTE intégrale uniquement
|
||||
|
||||
// Épisodes apportés par CETTE intégrale
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
const coveredByThis = new Set<number>()
|
||||
if (hash)
|
||||
for (const season of data.value!.seasons)
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrents?.some((et: any) => (et.infohash ?? extractHash(et)) === hash))
|
||||
covered.add(ep.id)
|
||||
coveredByThis.add(ep.id)
|
||||
|
||||
const bringsSomething = coveredByThis.size === 0
|
||||
|| [...coveredByThis].some(id => !covered.has(id) && !organizedEpisodeIds.value.has(id))
|
||||
|
||||
if (bringsSomething && !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))
|
||||
result.push({ key: `integrale-${i}`, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
|
||||
for (const id of coveredByThis) covered.add(id)
|
||||
}
|
||||
|
||||
// ── 2. Packs saison non couverts ──────────────────────────────
|
||||
@@ -606,12 +634,13 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
|
||||
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
|
||||
if (!hasUncovered) continue
|
||||
let addedAny = false
|
||||
season.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) { result.push({ key, torrent_url: t.torrent_url, magnet: t.magnet }); addedAny = true }
|
||||
})
|
||||
if (addedAny) for (const ep of season.episodes) covered.add(ep.id)
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
if (!alreadyHandled) {
|
||||
const t = season.torrents[0]
|
||||
result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
for (const ep of season.episodes) covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 3. Épisodes individuels non couverts ─────────────────────
|
||||
@@ -625,17 +654,9 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
}
|
||||
|
||||
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
|
||||
const hasSomethingToDownload = computed(() => {
|
||||
if (!data.value) return false
|
||||
if (data.value.torrents_integrale.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(`integrale-${i}`))) return true
|
||||
for (const season of data.value.seasons) {
|
||||
if (season.torrents?.length && season.organized_state !== 'complete' &&
|
||||
season.torrents.some((t: any, i: number) => !isAlreadyQueued(t) && !isDownloaded(i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`))) return true
|
||||
for (const ep of season.episodes)
|
||||
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
|
||||
// que le garde-fou versions ne laisserait passer aucun torrent.
|
||||
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
|
||||
|
||||
async function downloadAll(integraleIndex?: number) {
|
||||
downloadingAll.value = true
|
||||
@@ -654,12 +675,16 @@ async function downloadSeason(season: Season, packHash?: string) {
|
||||
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
|
||||
const seasonAny = season as any
|
||||
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
|
||||
seasonAny.torrents.forEach((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
if (!isAlreadyQueued(t) && !isDownloaded(key)) {
|
||||
torrents.push({ key, torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
})
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
const alreadyHandled = seasonAny.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
|
||||
if (!alreadyHandled) {
|
||||
const chosen = packHash
|
||||
? seasonAny.torrents.findIndex((t: any) => (t.infohash ?? extractHash(t)) === packHash)
|
||||
: 0
|
||||
const i = chosen >= 0 ? chosen : 0
|
||||
const t = seasonAny.torrents[i]
|
||||
torrents.push({ key: packKey(i), torrent_url: t.torrent_url, magnet: t.magnet })
|
||||
}
|
||||
} else {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
|
||||
|
||||
+102
-9
@@ -13,6 +13,8 @@
|
||||
:filters-import="filtersImport"
|
||||
:sort-options="sortOptions"
|
||||
:legend="legend"
|
||||
:selectable="auth.isAdmin"
|
||||
v-model:selecting="selecting"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
@@ -33,14 +35,22 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
|
||||
<RouterLink
|
||||
<component
|
||||
:is="selecting ? 'div' : RouterLink"
|
||||
v-for="serie in filtered"
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
|
||||
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="{ 'opacity-40': !serie.has_torrents && serie.download_state === 'none' }"
|
||||
:class="{
|
||||
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
|
||||
'cursor-pointer select-none': selecting,
|
||||
}"
|
||||
@click="selecting && toggleSelected(serie.id)"
|
||||
>
|
||||
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
|
||||
<div
|
||||
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
|
||||
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
|
||||
>
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
@@ -52,10 +62,22 @@
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selecting"
|
||||
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
|
||||
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
|
||||
>
|
||||
<Check v-if="selected.has(serie.id)" :size="13" />
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
|
||||
<Rss :size="11" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-1"
|
||||
:style="{ background: stateColor(serie.download_state) }"
|
||||
@@ -77,7 +99,20 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions de masse -->
|
||||
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
|
||||
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
|
||||
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
|
||||
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
|
||||
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
|
||||
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,10 +120,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Tv } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { Tv, Check, Rss, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore, type Serie } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -117,6 +152,7 @@ const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
@@ -143,6 +179,7 @@ const filtered = computed(() => {
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
|
||||
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
|
||||
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
@@ -163,6 +200,62 @@ const filtered = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
// ── Sélection multiple ───────────────────────────────────────
|
||||
const selecting = ref(false)
|
||||
const selected = ref<Set<number>>(new Set())
|
||||
const bulkBusy = ref(false)
|
||||
|
||||
watch(selecting, on => { if (!on) clearSelection() })
|
||||
|
||||
function toggleSelected(id: number) {
|
||||
const next = new Set(selected.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selected.value = next
|
||||
}
|
||||
function selectAllFiltered() { selected.value = new Set(filtered.value.map(s => s.id)) }
|
||||
function clearSelection() { selected.value = new Set() }
|
||||
|
||||
async function bulkRename() {
|
||||
bulkBusy.value = true
|
||||
try {
|
||||
const res = await fetch('/api/rename-all', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serie_ids: [...selected.value] }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
|
||||
const errs = data.errors?.length ?? 0
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
bulkBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkSync(enabled: boolean) {
|
||||
bulkBusy.value = true
|
||||
try {
|
||||
const targets = store.series.filter((s: Serie) => selected.value.has(s.id))
|
||||
const res = await fetch('/api/rss-sync/bulk', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
|
||||
for (const s of targets) s.rss_synced = enabled
|
||||
const n = data.changed
|
||||
toast(enabled
|
||||
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
|
||||
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
bulkBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
</svg>
|
||||
Actualiser
|
||||
</button>
|
||||
<button
|
||||
@click="forceMetadataRefresh"
|
||||
:disabled="forcingRefresh"
|
||||
class="btn-secondary"
|
||||
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
|
||||
>
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
|
||||
</button>
|
||||
<button
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
@@ -177,6 +185,7 @@ const { add: toast } = useToast()
|
||||
const loading = ref(true)
|
||||
const renamingAll = ref(false)
|
||||
const purgingNfo = ref(false)
|
||||
const forcingRefresh = ref(false)
|
||||
const series = ref<any[]>([])
|
||||
const nfoSupport = ref(false)
|
||||
const collapsed = ref<Set<number>>(new Set())
|
||||
@@ -254,6 +263,30 @@ async function renameSingle(ep: any, serie: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function forceMetadataRefresh() {
|
||||
forcingRefresh.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', {
|
||||
method : 'POST',
|
||||
headers : { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ force: true }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
|
||||
const parts = [`${data.count} séries rechargées`]
|
||||
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
|
||||
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
|
||||
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
|
||||
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
forcingRefresh.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeNfo() {
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
|
||||
purgingNfo.value = true
|
||||
|
||||
Reference in new issue
Block a user