673 lines
33 KiB
Vue
673 lines
33 KiB
Vue
<template>
|
||
<div class="bg-card border border-border rounded-xl">
|
||
<!-- Header saison -->
|
||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||
<div class="flex items-center gap-3">
|
||
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
|
||
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||
</button>
|
||
<div>
|
||
<h2 class="text-sm font-semibold text-primary">
|
||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
|
||
— {{ season.title }}
|
||
</span>
|
||
</h2>
|
||
<p class="text-xs text-muted mt-0.5">
|
||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
|
||
<span
|
||
v-if="season.organized_state !== 'none'"
|
||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
|
||
>
|
||
{{ season.organized_state === 'complete'
|
||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
<button
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||
>
|
||
<component :is="downloadIcon" />
|
||
Saison ▾
|
||
</button>
|
||
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||
<button
|
||
v-for="opt in uniquePackOptions"
|
||
:key="opt.infohash"
|
||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
|
||
>
|
||
<component :is="downloadIcon" />
|
||
{{ opt.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
v-else
|
||
@click="emit('downloadSeason', season)"
|
||
:disabled="downloadingSeason"
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
|
||
>
|
||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||
</button>
|
||
</template>
|
||
|
||
<!-- Pack saison unique -->
|
||
<button
|
||
v-if="season.torrents.length === 1"
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||
:class="canDownloadSeason
|
||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||
:disabled="!canDownloadSeason"
|
||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||
>
|
||
<component :is="seasonBtnIcon" />
|
||
{{ seasonBtnLabel }}
|
||
</button>
|
||
|
||
<!-- Dropdown packs saison multiples -->
|
||
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
|
||
<button
|
||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||
>
|
||
<component :is="downloadIcon" />
|
||
Saison ▾
|
||
</button>
|
||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-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 season.torrents"
|
||
:key="i"
|
||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
|
||
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
|
||
>
|
||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||
{{ groupLabels(season.torrents)[i] }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Progression saison -->
|
||
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
|
||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${seasonProgress.progress}%` }" />
|
||
</div>
|
||
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
|
||
</div>
|
||
|
||
<!-- Épisodes -->
|
||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||
<div
|
||
v-for="ep in season.episodes"
|
||
:key="ep.id"
|
||
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
|
||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<!-- Thumbnail -->
|
||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||
</div>
|
||
|
||
<!-- Infos épisode -->
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xs text-muted shrink-0 font-mono">
|
||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||
</span>
|
||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||
<span
|
||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||
>Hors Fankai</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||
<!-- Badge langue -->
|
||
<span
|
||
v-if="epLang(ep) === 'MULTI'"
|
||
title="Audio japonais + français"
|
||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-blue-500/10 border-blue-500/20 text-blue-300 select-none tracking-wide"
|
||
>
|
||
<!-- JP flag -->
|
||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||
<!-- FR flag -->
|
||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||
MULTI
|
||
</span>
|
||
<span
|
||
v-else-if="epLang(ep) === 'VOSTFR'"
|
||
title="Audio japonais · Sous-titres français"
|
||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-amber-500/10 border-amber-500/20 text-amber-300 select-none tracking-wide"
|
||
>
|
||
<!-- JP flag -->
|
||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||
<!-- FR flag -->
|
||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||
VOSTFR
|
||
</span>
|
||
<!-- Badge épisodes -->
|
||
<span
|
||
v-if="parseEpChap(ep.plot).episodes"
|
||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
|
||
:title="`Plage d'épisodes couverts`"
|
||
>{{ parseEpChap(ep.plot).episodes }}</span>
|
||
<!-- Badge chapitres -->
|
||
<span
|
||
v-if="parseEpChap(ep.plot).chapters"
|
||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
|
||
:title="`Plage de chapitres manga couverts`"
|
||
>{{ parseEpChap(ep.plot).chapters }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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)"
|
||
:disabled="epState(ep) !== 'idle'"
|
||
@click="handleEpBtnClick(ep)"
|
||
>
|
||
<component :is="epStateIcon(ep)" />
|
||
</button>
|
||
<!-- Dropdown quand plusieurs options de torrent -->
|
||
<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"
|
||
:key="i"
|
||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
|
||
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
|
||
>
|
||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||
{{ groupLabels(ep.torrents)[i] }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Actions épisode importé -->
|
||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||
<!-- Badge rename cliquable -->
|
||
<button
|
||
v-if="epNeedsRename(ep)"
|
||
@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</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>
|
||
|
||
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
|
||
<button
|
||
v-if="ep.plot"
|
||
@click.stop="togglePlot(ep.id)"
|
||
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
|
||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
|
||
title="Voir le synopsis"
|
||
>
|
||
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Barre progression épisode -->
|
||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
|
||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||
<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">{{ parseEpChap(ep.plot).cleanPlot }}</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, Trash2 } from 'lucide-vue-next'
|
||
|
||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[] }
|
||
|
||
const props = defineProps<{
|
||
season : any
|
||
collapsed : boolean
|
||
activeTorrents : ActiveTorrent[]
|
||
downloading : string[]
|
||
downloaded : string[]
|
||
organizedByEpisode: Record<string, any>
|
||
epActionLoading : Record<number, boolean>
|
||
downloadingSeason ?: boolean
|
||
nfoSupport ?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
toggle : [id: number]
|
||
download : [key: string, url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null]
|
||
downloadSeason : [season: any, packHash?: string]
|
||
renameEpisode : [ep: any, season: any]
|
||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||
unimportSeason : [season: any, deleteFiles: boolean]
|
||
}>()
|
||
|
||
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 })
|
||
|
||
// ── Helpers hash / progress ────────────────────────────────────
|
||
function extractHash(torrent: any): string | null {
|
||
// Préférer l'infohash direct (présent même si le magnet est absent)
|
||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||
if (!torrent?.magnet) return null
|
||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||
return m ? m[1].toLowerCase() : null
|
||
}
|
||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||
if (!hash) return null
|
||
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||
}
|
||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||
function isAlreadyQueued(torrent: any): boolean {
|
||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
|
||
if (torrent?.file_index != null) return false
|
||
const hash = extractHash(torrent)
|
||
if (!hash) return false
|
||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||
}
|
||
|
||
// ── Computed saison ────────────────────────────────────────────
|
||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||
const seasonProgress = computed(() => {
|
||
if (props.season.torrents && props.season.torrents.length > 1) {
|
||
for (const t of props.season.torrents) {
|
||
const prog = torrentProgress(extractHash(t))
|
||
if (prog) return prog
|
||
}
|
||
return null
|
||
}
|
||
return torrentProgress(extractHash(props.season.torrent))
|
||
})
|
||
const hasDownloadable = computed(() =>
|
||
props.season.episodes.some((ep: any) =>
|
||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||
)
|
||
)
|
||
const canDownloadSeason = computed(() =>
|
||
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
|
||
)
|
||
const seasonBtnLabel = computed(() => {
|
||
if (props.season.organized_state === 'complete') return 'Importé'
|
||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
|
||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
|
||
return 'Saison entière'
|
||
})
|
||
const seasonBtnIcon = computed(() => {
|
||
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
|
||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
|
||
if (!props.season.torrent) return h(X, { size: 12 })
|
||
return h(Download, { size: 12 })
|
||
})
|
||
|
||
// ── État épisode ───────────────────────────────────────────────
|
||
|
||
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
|
||
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
|
||
function epAnyDownloaded(ep: any): boolean {
|
||
if (isDownloaded(`ep-${ep.id}`)) return true
|
||
const count = ep.torrents?.length ?? 0
|
||
for (let i = 0; i < count; i++) { if (isDownloaded(`ep-${ep.id}-${i}`)) return true }
|
||
return false
|
||
}
|
||
function epAnyDownloading(ep: any): boolean {
|
||
if (isDownloading(`ep-${ep.id}`)) return true
|
||
const count = ep.torrents?.length ?? 0
|
||
for (let i = 0; i < count; i++) { if (isDownloading(`ep-${ep.id}-${i}`)) return true }
|
||
return false
|
||
}
|
||
|
||
function epProgress(ep: any): ActiveTorrent | null {
|
||
if (ep.organized) return null
|
||
|
||
// Chercher parmi toutes les options de torrent, pas seulement ep.torrent (= torrents[0])
|
||
const candidates: Array<{ torrent: any; key0: string; keyI: string }> = []
|
||
if (ep.torrents?.length > 0) {
|
||
ep.torrents.forEach((t: any, i: number) => candidates.push({ torrent: t, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}-${i}` }))
|
||
} else if (ep.torrent) {
|
||
candidates.push({ torrent: ep.torrent, key0: `ep-${ep.id}`, keyI: `ep-${ep.id}` })
|
||
}
|
||
|
||
for (const { torrent: t, key0, keyI } of candidates) {
|
||
const active = torrentProgress(extractHash(t))
|
||
if (!active) continue
|
||
|
||
if (t.file_index != null) {
|
||
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
|
||
if (active.files && active.files.length > 0) {
|
||
const file = active.files.find(f => f.index === t.file_index)
|
||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
||
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
|
||
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
|
||
return { ...active, progress: Math.round(file.progress * 100) }
|
||
}
|
||
if (!sessionDl) continue
|
||
} else {
|
||
// Pas de données par fichier : ignorer si pas de session active
|
||
if (!sessionDl) continue
|
||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||
}
|
||
}
|
||
return active
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function handleEpBtnClick(ep: any) {
|
||
if (ep.organized) return
|
||
if (ep.torrents && ep.torrents.length > 1) {
|
||
// Plusieurs options → toggle dropdown
|
||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||
return
|
||
}
|
||
// Option unique → téléchargement direct
|
||
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
|
||
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
|
||
}
|
||
}
|
||
|
||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||
// Vert uniquement quand importé dans la bibliothèque
|
||
if (ep.organized) return 'done'
|
||
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
|
||
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
|
||
const prog = epProgress(ep)
|
||
if (prog) return 'loading'
|
||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||
return 'idle'
|
||
}
|
||
function epStateIcon(ep: any) {
|
||
const state = epState(ep)
|
||
if (state === 'done') return h(Check, { size: 14 })
|
||
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
|
||
if (state === 'unavailable') return h(X, { size: 14 })
|
||
return h(Download, { size: 14 })
|
||
}
|
||
function epBtnClass(ep: any): string {
|
||
const state = epState(ep)
|
||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||
}
|
||
|
||
// ── Rename helpers ─────────────────────────────────────────────
|
||
function epExpectedName(ep: any): string {
|
||
const entry = props.organizedByEpisode[String(ep.id)]
|
||
if (!entry) return ''
|
||
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
|
||
if (props.nfoSupport) {
|
||
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
|
||
} else {
|
||
// Préférer le formatted_name du torrent qui correspond au fichier importé.
|
||
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
|
||
// cherche le premier torrent qui a un formatted_name.
|
||
const usedHash = entry.hash?.toLowerCase() ?? null
|
||
const matchedName = usedHash
|
||
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
|
||
: null
|
||
const name = matchedName
|
||
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
|
||
?? ep.formatted_name
|
||
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
|
||
}
|
||
}
|
||
function epNeedsRename(ep: any): boolean {
|
||
const entry = props.organizedByEpisode[String(ep.id)]
|
||
if (!entry) return false
|
||
return entry.needs_rename === true
|
||
}
|
||
|
||
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
||
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
|
||
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
|
||
/**
|
||
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
|
||
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
|
||
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
|
||
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
|
||
*/
|
||
function _parseEpChap(plot: string | null | undefined): {
|
||
episodes: string | null
|
||
chapters: string | null
|
||
cleanPlot: string
|
||
} {
|
||
const text = plot ?? ''
|
||
|
||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
||
|
||
const episodes = epMatch
|
||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||
: null
|
||
const chapters = chapMatch
|
||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||
: null
|
||
|
||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
||
let cleanPlot = text
|
||
if (episodes || chapters) {
|
||
cleanPlot = text
|
||
.split(/\r?\n/)
|
||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
||
.join('\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim()
|
||
}
|
||
|
||
return { episodes, chapters, cleanPlot }
|
||
}
|
||
function parseEpChap(plot: string | null | undefined) {
|
||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||
const result = _parseEpChap(plot)
|
||
_epChapCache.set(plot, result)
|
||
return result
|
||
}
|
||
|
||
// ── Détection langue ───────────────────────────────────────────
|
||
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||
const sources: string[] = [
|
||
ep.formatted_name ?? '',
|
||
ep.torrent?.torrent_name ?? '',
|
||
ep.torrent?.raw ?? '',
|
||
...((ep.torrents ?? []) as any[]).map((t: any) => `${t.torrent_name ?? ''} ${t.raw ?? ''}`),
|
||
]
|
||
for (const s of sources) {
|
||
if (!s) continue
|
||
if (/\bMULTI\b/i.test(s)) return 'MULTI'
|
||
if (/\bVOSTFR\b/i.test(s)) return 'VOSTFR'
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ── Labels de groupe ───────────────────────────────────────────
|
||
// Si tous les torrent_name sont présents ET distincts → on les utilise.
|
||
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
|
||
function labelFromRaw(raw: string, index: number): string {
|
||
if (!raw) return `Option ${index + 1}`
|
||
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||
}
|
||
|
||
function groupLabels(torrents: any[]): string[] {
|
||
const names = torrents.map((t: any) => t.torrent_name ?? null)
|
||
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
|
||
const allUnique = allPresent && new Set(names).size === names.length
|
||
if (allUnique) return names as string[]
|
||
// Fallback : titre Nyaa brut
|
||
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
|
||
}
|
||
|
||
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
|
||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales
|
||
const uniquePackOptions = computed(() => {
|
||
if (props.season.torrents?.length) return []
|
||
const seen = new Set<string>()
|
||
const raw: { infohash: string; torrent: any }[] = []
|
||
for (const ep of props.season.episodes) {
|
||
if (!ep.available) continue
|
||
for (const t of (ep.torrents ?? [])) {
|
||
const hash = t.infohash ?? extractHash(t)
|
||
if (!hash || seen.has(hash)) continue
|
||
seen.add(hash)
|
||
raw.push({ infohash: hash, torrent: t })
|
||
}
|
||
}
|
||
if (raw.length <= 1) return []
|
||
const labels = groupLabels(raw.map(r => r.torrent))
|
||
return raw.map((r, i) => ({ infohash: r.infohash, label: labels[i] }))
|
||
})
|
||
|
||
function formatDate(d: string): string {
|
||
if (!d) return ''
|
||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||
}
|
||
function formatDuration(seconds: number): string {
|
||
if (!seconds || seconds <= 0) return ''
|
||
const h = Math.floor(seconds / 3600)
|
||
const m = Math.floor((seconds % 3600) / 60)
|
||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||
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() {
|
||
epOptionsOpen.value = null
|
||
seasonMenuOpen.value = false
|
||
}
|
||
|
||
onMounted(() => document.addEventListener('click', closeAllMenus))
|
||
onUnmounted(() => document.removeEventListener('click', closeAllMenus))
|
||
|
||
defineExpose({ closeEpMenu: closeAllMenus })
|
||
</script>
|