feat: actions de masse sur les séries et fiabilisation des imports et correction protection admin

This commit is contained in:
ElPouki committed 2026-09-11 15:10:40 +02:00
1 parent 7479ddf8eb
commit 7f06dd654a
19 files changed
+637 -258

No files matched your search

+16 -1
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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