feat: ajout d'une variable d'environment pour augmenter la durée du token ou le desactiver avec "never" ou 0
feat: ajout du .env dans les dossiers du binaires pour pouvoir changer leur valeurs fix: utilisation du fichier torrent avec le magnet pour les bug de metadonnées fix: prise en charge de multiple torrent sur les telechargement d'episodes
This commit is contained in:
1 parent
79b6a8bbb4
commit
f897116e30
10 files changed
+249
-101
No files matched your search
+15
-3
@@ -7,6 +7,18 @@ import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
// AUTH_TOKEN_EXPIRY : durée de validité du token JWT
|
||||
// Exemples : "1d", "7d", "30d", "1y", "never" / "0" = pas d'expiration
|
||||
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
|
||||
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
|
||||
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
|
||||
|
||||
function signToken(username: string): string {
|
||||
return NO_EXPIRY
|
||||
? jwt.sign({ username }, JWT_SECRET)
|
||||
: jwt.sign({ username }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
}
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
@@ -66,7 +78,7 @@ export function authSetup(req: Request, res: Response): void {
|
||||
writeAuth({ username, passwordHash })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
@@ -91,9 +103,9 @@ export function authLogin(req: Request, res: Response): void {
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
const token = signToken(username)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
logger.info('auth', `Connexion réussie pour "${username}"`)
|
||||
logger.info('auth', `Connexion réussie pour "${username}" (expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,37 @@ import path from 'path'
|
||||
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
|
||||
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
|
||||
// Doit s'exécuter avant tout autre module qui lit process.env
|
||||
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
|
||||
;(() => {
|
||||
if (!_isBunBinary) return
|
||||
const binDir = path.dirname((process as any).execPath)
|
||||
const envPath = path.join(binDir, '.env')
|
||||
if (!fs.existsSync(envPath)) return
|
||||
try {
|
||||
const lines = fs.readFileSync(envPath, 'utf-8').split('\n')
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let val = line.slice(eq + 1).trim()
|
||||
// Supprimer les guillemets optionnels
|
||||
if ((val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
|
||||
if (key && !(key in process.env)) process.env[key] = val
|
||||
}
|
||||
console.log(`[config] .env chargé depuis ${envPath}`)
|
||||
} catch (err) {
|
||||
console.warn(`[config] Impossible de charger .env : ${err}`)
|
||||
}
|
||||
})()
|
||||
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
@@ -13,12 +13,15 @@ import { recentOrganized } from '../lib/notifs.js'
|
||||
const router = Router()
|
||||
|
||||
router.post('/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.magnet ?? req.body.torrent_url
|
||||
// Préférer le fichier .torrent au magnet : métadonnées immédiates, pas de dépendance DHT/trackers
|
||||
const torrent_url: string | null = req.body.torrent_url ?? null
|
||||
const magnet : string | null = req.body.magnet ?? null
|
||||
const url = torrent_url ?? magnet
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
|
||||
const file_path: string | null = req.body.file_path ?? null
|
||||
logger.info('api', `Téléchargement demandé${file_index != null ? ` (fichier #${file_index})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path })
|
||||
const file_path : string | null = req.body.file_path ?? null
|
||||
logger.info('api', `Téléchargement demandé via ${torrent_url ? '.torrent' : 'magnet'}${file_index != null ? ` (fichier #${file_index})` : ''}`)
|
||||
const results = await dispatchDownload(url, { file_index, file_path, magnet })
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
|
||||
+36
-12
@@ -51,8 +51,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any> = {}
|
||||
const episodeTorrentMap : Record<number, any[]> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any[]> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
@@ -85,7 +85,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
|
||||
if (!seasonTorrentMapBySn[season.season_number]) seasonTorrentMapBySn[season.season_number] = []
|
||||
seasonTorrentMapBySn[season.season_number].push({ torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false })
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
@@ -96,8 +97,9 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null }
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const isOrg = orgFiles[String(ep.id)] !== undefined
|
||||
@@ -110,15 +112,20 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
// Fallback : si l'épisode n'a pas de torrent individuel,
|
||||
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack)
|
||||
if (!episodeTorrentMap[ep.id]) {
|
||||
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
|
||||
// On ajoute TOUTES les entrées valides (un pack par path unique).
|
||||
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
|
||||
const seenPackHashes = new Set<string>()
|
||||
for (const pathEntry of (ep.paths ?? [])) {
|
||||
if (typeof pathEntry !== 'object' || !pathEntry.infohash) continue
|
||||
const pack = torrentByHash.get(pathEntry.infohash.toLowerCase())
|
||||
const hashKey = pathEntry.infohash.toLowerCase()
|
||||
if (seenPackHashes.has(hashKey)) continue
|
||||
const pack = torrentByHash.get(hashKey)
|
||||
if (!pack) continue
|
||||
episodeTorrentMap[ep.id] = { torrent_url: pack.torrent_url, magnet: pack.magnet, type: pack.type, raw: pack.raw, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null }
|
||||
seenPackHashes.add(hashKey)
|
||||
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
|
||||
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, type: pack.type, raw: pack.raw, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null })
|
||||
availableEpisodeIds.add(ep.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +134,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => {
|
||||
const epTorrent = episodeTorrentMap[ep.id] ?? null
|
||||
const epTorrents = episodeTorrentMap[ep.id] ?? []
|
||||
const epTorrent = epTorrents[0] ?? null
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
@@ -142,7 +150,15 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) }
|
||||
const resolvedFankai = fankai ?? true
|
||||
return {
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent: epTorrent ? { ...epTorrent, fankai: resolvedFankai } : null,
|
||||
torrents: epTorrents.map((t: any) => ({ ...t, fankai: resolvedFankai ?? t.fankai ?? true })),
|
||||
fankai,
|
||||
organized: organizedEpisodeIds.has(ep.id),
|
||||
}
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
@@ -154,7 +170,15 @@ router.get('/series/:id', requireAuth, async (req, res) => {
|
||||
: total > 0 && orgCount >= total
|
||||
? 'complete'
|
||||
: 'partial'
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgState, organized_count: orgCount, episodes: eps }
|
||||
const seasonTorrents = seasonTorrentMapBySn[season.season_number] ?? []
|
||||
return {
|
||||
...season,
|
||||
torrent: seasonTorrents[0] ?? null,
|
||||
torrents: seasonTorrents,
|
||||
organized_state: orgState,
|
||||
organized_count: orgCount,
|
||||
episodes: eps,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
|
||||
|
||||
@@ -58,7 +58,8 @@ export interface TorrentInfo {
|
||||
|
||||
export interface DownloadOptions {
|
||||
file_index?: number | null
|
||||
file_path?: string | null
|
||||
file_path ?: string | null
|
||||
magnet ?: string | null // conservé pour extraire le hash même quand on envoie le .torrent
|
||||
}
|
||||
|
||||
export interface TorrentClientDriver {
|
||||
|
||||
@@ -216,7 +216,9 @@ const QB: TorrentClientDriver = {
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
const sid = await qbLogin(config)
|
||||
|
||||
// Extraire le hash depuis l'URL (si magnet) ou depuis options.magnet (si .torrent 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
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
|
||||
@@ -290,6 +290,7 @@ const RT: TorrentClientDriver = {
|
||||
|
||||
async add(config, url, options?: DownloadOptions) {
|
||||
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]?.toUpperCase() ?? null
|
||||
|
||||
if (options?.file_index != null && hash) {
|
||||
|
||||
@@ -25,42 +25,7 @@
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<!-- Titre + boutons actions -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
|
||||
<button
|
||||
@click="emit('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 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 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-500/40 transition shrink-0"
|
||||
>
|
||||
<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="emit('unimport', 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="emit('unimport', 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>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -77,28 +42,14 @@
|
||||
<p v-if="serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
serie : any
|
||||
organizedByEpisode : Record<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
openManualImport: []
|
||||
unimport : [deleteFile: boolean]
|
||||
}>()
|
||||
|
||||
const unimportMenuOpen = ref(false)
|
||||
|
||||
defineExpose({ closeUnimportMenu: () => { unimportMenuOpen.value = false } })
|
||||
defineProps<{ serie: any }>()
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Pas de pack saison : bouton "Saison" qui déclenche les épisodes individuels -->
|
||||
<button
|
||||
v-if="!season.torrent && hasDownloadable"
|
||||
v-if="season.torrents.length === 0 && hasDownloadable"
|
||||
@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"
|
||||
@@ -42,9 +42,9 @@
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
</button>
|
||||
|
||||
<!-- Pack saison disponible -->
|
||||
<!-- Pack saison unique -->
|
||||
<button
|
||||
v-if="season.torrent"
|
||||
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'
|
||||
@@ -55,6 +55,30 @@
|
||||
<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" />
|
||||
{{ torrentLabel(t, i) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,6 +134,30 @@
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
|
||||
<!-- Options torrent supplémentaires pour épisodes multi-torrent -->
|
||||
<div v-if="!ep.organized && ep.torrents && ep.torrents.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
@click="epOptionsOpen = epOptionsOpen === ep.id ? null : ep.id"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-muted hover:text-primary transition-colors"
|
||||
title="Autres options"
|
||||
>
|
||||
<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="epOptionsOpen === ep.id" 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 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), epOptionsOpen = null)"
|
||||
>
|
||||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ torrentLabel(t, i) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions épisode importé -->
|
||||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<span
|
||||
@@ -180,9 +228,12 @@ const emit = defineEmits<{
|
||||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||||
}>()
|
||||
|
||||
const epMenuOpen = ref<number | null>(null)
|
||||
const epMenuOpen = ref<number | null>(null)
|
||||
const epOptionsOpen = ref<number | null>(null)
|
||||
const seasonMenuOpen = 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 {
|
||||
@@ -208,7 +259,16 @@ function isAlreadyQueued(torrent: any): boolean {
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => torrentProgress(extractHash(props.season.torrent)))
|
||||
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}`)
|
||||
@@ -294,6 +354,16 @@ function epNeedsRename(ep: any): boolean {
|
||||
return !!expected && expected !== entry.dest_filename
|
||||
}
|
||||
|
||||
function torrentLabel(torrent: any, index: number): string {
|
||||
const raw = torrent.raw ?? ''
|
||||
const quality = raw.match(/\b(2160p|4K|1080p|720p|480p)\b/i)?.[1]?.toUpperCase()
|
||||
const lang = raw.match(/\b(VOSTFR|MULTI|VF|VO|FR|EN)\b/i)?.[1]?.toUpperCase()
|
||||
if (quality && lang) return `${quality} · ${lang}`
|
||||
if (quality) return quality
|
||||
if (lang) return lang
|
||||
return raw.length > 40 ? raw.slice(0, 40) + '…' : (raw || `Option ${index + 1}`)
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
@@ -306,5 +376,5 @@ function formatDuration(seconds: number): string {
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
defineExpose({ closeEpMenu: () => { epMenuOpen.value = null } })
|
||||
defineExpose({ closeEpMenu: () => { epMenuOpen.value = null; epOptionsOpen.value = null; seasonMenuOpen.value = false } })
|
||||
</script>
|
||||
+77
-24
@@ -14,27 +14,57 @@
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero -->
|
||||
<SerieHero
|
||||
:serie="data.serie"
|
||||
:organized-by-episode="organizedByEpisode"
|
||||
@open-manual-import="openManualImport"
|
||||
@unimport="(del) => unimportSerie(del)"
|
||||
/>
|
||||
<!-- Hero — poster, titre, métadonnées, synopsis -->
|
||||
<SerieHero :serie="data.serie" />
|
||||
|
||||
<!-- Zone d'actions téléchargement -->
|
||||
<div
|
||||
v-if="data.torrents_integrale.length > 0 || hasNonIntegraleDownloadables"
|
||||
class="px-4 md:px-8 py-4 flex items-center justify-between gap-4 border-b border-border"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-primary shrink-0">Saisons</h2>
|
||||
<div class="flex flex-wrap gap-2 justify-end">
|
||||
<!-- Tout télécharger (saisons + épisodes individuels) -->
|
||||
<!-- Barre d'actions -->
|
||||
<div class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
|
||||
<!-- Gauche : gestion bibliothèque -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Import manuel -->
|
||||
<button
|
||||
@click="openManualImport"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20 transition"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Tout télécharger -->
|
||||
<button
|
||||
v-if="hasNonIntegraleDownloadables"
|
||||
@click="downloadAll"
|
||||
:disabled="downloadingAll"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
|
||||
@@ -60,7 +90,6 @@
|
||||
<component :is="integraleIcon(`integrale-${i}`, t)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
</button>
|
||||
<!-- Progression -->
|
||||
<div v-if="isDownloaded(`integrale-${i}`) && integraleProgress(t) && integraleProgress(t)!.progress < 100" class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${integraleProgress(t)!.progress}%` }" />
|
||||
</div>
|
||||
@@ -130,6 +159,7 @@ const organizedByEpisode = ref<Record<string, any>>({})
|
||||
const mediaPath = ref('/')
|
||||
const nfoSupport = ref(false)
|
||||
const epActionLoading = ref<Record<number, boolean>>({})
|
||||
const unimportMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
|
||||
@@ -219,9 +249,16 @@ function collectDownloadables() {
|
||||
}
|
||||
})
|
||||
for (const season of data.value.seasons) {
|
||||
if (!season.torrent || isAlreadyQueued(season.torrent) || isDownloaded(`season-${season.id}`) || season.organized_state === 'complete') continue
|
||||
result.push({ key: `season-${season.id}`, torrent_url: season.torrent.torrent_url, magnet: season.torrent.magnet })
|
||||
for (const ep of season.episodes) covered.add(ep.id)
|
||||
if (!season.torrents || season.torrents.length === 0 || season.organized_state === 'complete') 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)
|
||||
}
|
||||
for (const season of data.value.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
@@ -242,7 +279,13 @@ const hasNonIntegraleDownloadables = computed(() => {
|
||||
for (const season of data.value!.seasons) for (const ep of season.episodes) covered.add(ep.id)
|
||||
})
|
||||
for (const season of data.value.seasons) {
|
||||
if (season.torrent && !isAlreadyQueued(season.torrent) && !isDownloaded(`season-${season.id}`) && season.organized_state !== 'complete') return true
|
||||
if (season.torrents && season.torrents.length > 0 && season.organized_state !== 'complete') {
|
||||
const hasAvailablePack = season.torrents.some((t: any, i: number) => {
|
||||
const key = i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`
|
||||
return !isAlreadyQueued(t) && !isDownloaded(key)
|
||||
})
|
||||
if (hasAvailablePack) return true
|
||||
}
|
||||
for (const ep of season.episodes) {
|
||||
if (ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`) && !covered.has(ep.id)) return true
|
||||
}
|
||||
@@ -265,10 +308,16 @@ async function downloadAll() {
|
||||
async function downloadSeason(season: Season) {
|
||||
downloadingSeason.value[season.id] = true
|
||||
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
|
||||
if (season.torrent && !isAlreadyQueued(season.torrent) && !isDownloaded(`season-${season.id}`) && season.organized_state !== 'complete') {
|
||||
torrents.push({ key: `season-${season.id}`, torrent_url: season.torrent.torrent_url, magnet: season.torrent.magnet })
|
||||
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 })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
for (const ep of (season as any).episodes ?? []) {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`)) continue
|
||||
torrents.push({ key: `ep-${ep.id}`, torrent_url: ep.torrent.torrent_url, magnet: ep.torrent.magnet, file_index: ep.torrent.file_index ?? null, file_path: ep.torrent.file_path ?? null })
|
||||
}
|
||||
@@ -325,11 +374,15 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
finally { epActionLoading.value[ep.id] = false }
|
||||
}
|
||||
|
||||
const closeMenus = () => { unimportMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load(); fetchSettings(); fetchOrganized(); fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
document.addEventListener('click', closeMenus)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
document.removeEventListener('click', closeMenus)
|
||||
})
|
||||
</script>
|
||||
Reference in new issue
Block a user