Files
FanKarr/src/views/SerieView.vue
T

601 lines
27 KiB
Vue

<template>
<div>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-sm">Chargement…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Hero avec fanart -->
<div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
<img
:src="data.serie.fanart_image"
class="w-full h-full object-cover opacity-15"
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
/>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
</div>
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
</div>
<!-- Infos -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<!-- Retour -->
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
<ArrowLeft :size="13" />
Séries
</RouterLink>
<!-- Titre + bouton import manuel -->
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
<button
@click="openManualImport"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import manuel
</button>
</div>
<div class="flex flex-wrap items-center gap-2">
<span v-if="data.serie.year" class="text-sm text-muted">{{ data.serie.year }}</span>
<span
v-if="data.serie.status"
class="text-xs px-2 py-0.5 rounded-full border"
:class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-hover text-muted border-border'"
>
{{ data.serie.status }}
</span>
</div>
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!-- Boutons intégrale -->
<div class="flex flex-wrap gap-2 mt-1">
<button
v-for="(t, i) in data.torrents_integrale"
:key="i"
:title="t.raw"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[
t.label === 'Intégrale'
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
(isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t))
? 'opacity-50 cursor-not-allowed' : ''
]"
:disabled="isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t)"
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
>
<EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
</button>
</div>
<!-- Progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
</template>
</div>
</div>
</div>
<!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
<div
v-for="season in data.seasons"
:key="season.id"
class="bg-card border border-border rounded-xl overflow-hidden"
>
<!-- 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="toggleSeason(season.id)">
<ChevronUp
:size="15"
class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '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="seasonAvailableCount(season) > 0">
· {{ seasonAvailableCount(season) }}/{{ 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}/${seasonAvailableCount(season) || season.episodes.length} importés` }}
</span>
</p>
</div>
</div>
<!-- Bouton pack saison -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
: 'text-muted border-border cursor-not-allowed opacity-50'"
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
</button>
</div>
<!-- Progression saison -->
<div
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.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: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
/>
</div>
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
</div>
<!-- Épisodes -->
<div v-if="!collapsedSeasons.has(season.id)" 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"
: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">
<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>
</div>
</div>
<!-- Bouton état unique -->
<button
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
:class="epBtnClass(ep)"
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<EpStateIcon :state="epState(ep)" :size="14" />
</button>
<!-- Actions épisode importé -->
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
<!-- Badge rename disponible -->
<span
v-if="epNeedsRename(ep)"
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 cursor-default"
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
>
Rename
</span>
<!-- Menu actions -->
<div class="relative" @click.stop>
<button
@click="epMenuOpen = epMenuOpen === ep.id ? null : ep.id"
class="w-6 h-6 flex items-center justify-center rounded text-muted hover:text-primary transition-colors"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<div
v-if="epMenuOpen === ep.id"
class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-44 shadow-xl flex flex-col gap-0.5"
>
<button
v-if="epNeedsRename(ep)"
@click="renameEpisode(ep, season); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Renommer
</button>
<button
@click="unimportEpisode(ep, season, false); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
Désimporter
</button>
<button
@click="unimportEpisode(ep, season, true); epMenuOpen = null"
:disabled="epActionLoading[ep.id]"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
Supprimer le fichier
</button>
</div>
</div>
</template>
</div>
<!-- Barre progression épisode -->
<div
v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.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: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
:serie-name="data.serie.title"
:seasons="data.seasons"
:organized="organizedByEpisode"
:initial-path="mediaPath"
@close="manualImportOpen = false"
@imported="load(); fetchOrganized(); manualImportOpen = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
import { useSeriesStore } from '@/stores/series'
import { useToast } from '@/composables/useToast'
import ManualImportModal from '@/components/ManualImportModal.vue'
import type { Season } from '@/stores/series'
// ── Icône d'état du bouton ────────────────────────────────────
const EpStateIcon = defineComponent({
props: {
state: { type: String, default: 'idle' },
size : { type: Number, default: 14 },
},
setup(props) {
return () => {
if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
if (props.state === 'done') return h(Check, { size: props.size })
if (props.state === 'unavailable') return h(X, { size: props.size })
return h(Download, { size: props.size })
}
},
})
// ── Barre de progression ──────────────────────────────────────
const ProgressBar = defineComponent({
props: { progress: { type: Number, default: null } },
setup(props) {
return () => {
if (props.progress == null || props.progress >= 100) return null
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
h('div', {
class: 'h-full bg-accent rounded-full transition-all duration-500',
style: { width: `${props.progress}%` },
}),
])
}
},
})
const route = useRoute()
const store = useSeriesStore()
const { add: toast } = useToast()
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string[]>([])
const downloaded = ref<string[]>([])
const manualImportOpen = ref(false)
const organizedByEpisode = ref<Record<string, any>>({})
const mediaPath = ref('/')
const epMenuOpen = ref<number | null>(null)
const epActionLoading = ref<Record<number, boolean>>({})
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
interface ActiveTorrent { hash: string; progress: number; state: string }
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
const data = computed(() => store.currentSerie)
async function fetchOrganized() {
try {
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
}
async function fetchSettings() {
try {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
mediaPath.value = s.mediaPath || '/'
}
} catch {}
}
function openManualImport() {
fetchOrganized()
manualImportOpen.value = true
}
// ── Polling downloads ─────────────────────────────────────────
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const list: any[] = await res.json()
activeTorrents.value = list.map(t => ({
hash : t.hash,
progress: t.progress ?? 0,
state : t.state,
}))
} catch {}
}
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
function extractHash(torrent: { magnet?: string | null } | null | undefined): string | null {
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 isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── États boutons ─────────────────────────────────────────────
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
function epState(ep: any): BtnState {
if (ep.organized) return 'done'
if (isDownloaded(`ep-${ep.id}`)) return 'done'
if (isAlreadyQueued(ep.torrent)) return 'done'
const prog = torrentProgress(extractHash(ep.torrent))
if (prog && prog.progress < 100) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
if (isDownloading(`ep-${ep.id}`)) return 'loading'
return 'idle'
}
function epBtnState(key: string, torrent: any): BtnState {
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
if (isDownloading(key)) return 'loading'
return 'idle'
}
function seasonBtnState(season: any): BtnState {
if (season.organized_state === 'complete') return 'done'
if (isDownloaded(`season-${season.id}`)) return 'done'
if (isAlreadyQueued(season.torrent)) return 'done'
if (isDownloading(`season-${season.id}`)) return 'loading'
if (!season.torrent) return 'unavailable'
return 'idle'
}
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'
}
// ── Actions ───────────────────────────────────────────────────
function seasonAvailableCount(season: Season) {
return season.episodes.filter((e: any) => e.available).length
}
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
}
async function download(key: string, torrent_url: string | null, magnet: string | null) {
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet)
removeDownloading(key)
if (result.success) {
addDownloaded(key)
toast('Téléchargement lancé ✓', 'success')
fetchActiveDownloads()
} else {
toast(result.error ?? 'Erreur inconnue', 'error')
}
}
function load() {
store.fetchSerieDetail(Number(route.params.id))
}
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`
}
// ── Rename / Désimport épisode ────────────────────────────────
// Calcule le nom attendu selon les settings actuels
function epExpectedName(ep: any): string {
const entry = organizedByEpisode.value[String(ep.id)]
if (!entry) return ''
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
// On ne connaît pas nfoSupport ici — on utilise ce qui est dispo
// Si nfo_filename existe dans ep, c'est le nom attendu en mode nfo
// On compare avec le nom actuel pour détecter si un rename est possible
if (ep.nfo_filename) {
const nfoName = ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
return nfoName
}
if (ep.formatted_name?.trim()) {
return ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
}
return entry.dest_filename
}
function epNeedsRename(ep: any): boolean {
const entry = organizedByEpisode.value[String(ep.id)]
if (!entry) return false
const expected = epExpectedName(ep)
return !!expected && expected !== entry.dest_filename
}
async function renameEpisode(ep: any, season: any) {
epActionLoading.value[ep.id] = true
try {
// Trouver le hash du torrent pour cet épisode
const torrentHash = ep.paths?.[0]?.infohash ?? null
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur rename', 'error'); return }
if (data.renamed) {
toast(`Renommé : ${data.new_name}`, 'success')
await fetchOrganized()
} else {
toast(data.message ?? 'Nom déjà correct', 'success')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
epActionLoading.value[ep.id] = false
}
}
async function unimportEpisode(ep: any, season: any, deleteFile: boolean) {
epActionLoading.value[ep.id] = true
try {
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
await fetchOrganized()
load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
epActionLoading.value[ep.id] = false
}
}
onMounted(() => {
load()
fetchSettings()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
// Fermer le menu épisode au clic extérieur
document.addEventListener('click', () => { epMenuOpen.value = null })
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
document.removeEventListener('click', () => { epMenuOpen.value = null })
})
</script>