feat(torrent-clients): ajout du driver Real-Debrid

feat(organize): vérification de complétion par fichier avant d'importer
- Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent
- Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver
- Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink

fix(organize): retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows)

fix(organize): suppression des entrées orphelines lors de la migration d'IDs
- Quand l'ancien ID est orphelin et le nouvel ID déjà présent, l'orphelin est supprimé au lieu d'être conservé

fix(qbittorrent): suppression de paused/stopped à l'ajout du torrent
- Le torrent est ajouté normalement pour permettre la récupération immédiate des métadonnées
- Premier poll de qbApplyFilePriority réduit à 100 ms

feat(serie): badges épisodes et chapitres extraits du synopsis
- Regex sur ep.plot pour extraire les plages "Ép. 628–634" et "Ch. 700–706"
- La phrase de couverture est retirée du synopsis affiché
- Badges violet (épisodes) et teal (chapitres) à côté des badges MULTI/VOSTFR

feat(serie): lien wiki dans le header de la série si serie.wiki est défini

fix(serie): header mobile — items-start + aspect-[2/3] sur le poster
This commit is contained in:
MasutaYunikon committed 2026-05-06 22:20:22 +02:00
1 parent 39463bfbcb
commit 2a77a38655
10 files changed
+454 -24

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ import transmissionDriver from './torrent-clients/transmission.js'
import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
@@ -33,6 +34,7 @@ registerDriver(transmissionDriver)
registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
+66 -4
View File
@@ -253,6 +253,26 @@ function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
}
/**
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
*/
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fsp.unlink(filePath)
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
}
}
}
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
@@ -270,7 +290,13 @@ function sanitizeDirName(name: string): string {
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData : any[],
completedFileNames: Set<string> | null = null,
) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
@@ -325,6 +351,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
const candidates = filePath ? [
path.join(savePath, filePath),
path.join(completePath, filePath),
@@ -356,7 +388,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
@@ -385,6 +417,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Vérification de complétion : si on a les infos de progression par fichier,
// on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
result.skipped++
continue
}
const candidates = [
path.join(savePath, fullPath),
path.join(completePath, fullPath),
@@ -433,7 +473,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
}
result.done++
debug(`${nfo_filename} → Saison ${season_number}`)
@@ -479,7 +519,29 @@ parentPort?.on('message', async (msg: any) => {
}
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
// on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
.filter(Boolean)
)
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
+3 -2
View File
@@ -397,8 +397,9 @@ export async function migrateOrganizedEpisodeIds(
if (newId && newId !== oldId) {
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
logger.warn('organize', `Migration ID : conflit ep ${oldId} → ${newId} — entrée existante conservée`)
newEpisodes[oldIdStr] = entry
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
+26 -1
View File
@@ -36,7 +36,8 @@ export interface SavedClient {
export interface TorrentFileProgress {
index : number
progress: number // 0–100
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
progress: number // 0–1
}
export interface TorrentInfo {
@@ -70,6 +71,7 @@ export interface TorrentClientDriver {
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -341,4 +343,27 @@ export async function dispatchList(
}
return results
}
// ─── Dispatch file progress ────────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
export async function dispatchGetFiles(
hash : string,
clientUuid?: string,
): Promise<TorrentFileProgress[]> {
const clients = clientUuid
? loadClients().filter(c => c.uuid === clientUuid)
: loadClients()
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver?.getFiles) continue
try {
const files = await driver.getFiles(client.config, hash)
if (files.length > 0) return files
} catch {}
}
return []
}
+23 -9
View File
@@ -57,9 +57,9 @@ async function qbApplyFilePriority(
fileIndex: number,
resume : boolean,
): Promise<void> {
// 60 tentatives × 500 ms = 30 s max
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 500))
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
@@ -223,12 +223,30 @@ const QB: TorrentClientDriver = {
save_path : t.save_path,
category : t.category ?? '',
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({ index: i, progress: Math.round((f.progress ?? 0) * 100) }))
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0, // 0–1
}))
: undefined,
} satisfies TorrentInfo
})
},
async getFiles(config, hash) {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
})
if (!res.ok) return []
const data: any[] = await res.json()
return data.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0,
}))
},
async add(config, url, options?: DownloadOptions) {
const sid = await qbLogin(config)
@@ -259,15 +277,11 @@ const QB: TorrentClientDriver = {
} catch {}
}
// Ajout du torrent en pause immédiate (toutes versions qBit)
// paused=true → qBit < 5.0
// stopped=true → qBit ≥ 5.0
// Ajout normal du torrent (pas en pause) pour que qBit récupère les métadonnées immédiatement
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('paused', 'true')
form.append('stopped', 'true')
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
@@ -276,7 +290,7 @@ const QB: TorrentClientDriver = {
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (en pause, sélection fichier ${options.file_index} en attente)`)
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
+254
View File
@@ -0,0 +1,254 @@
/**
* Real-Debrid Driver
*
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
* puis les fichiers sont accessibles via des liens directs (HTTPS).
*
* Workflow FanKarr :
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
* 3. remove() → DELETE /torrents/delete/{id}
*
* Le save_path de chaque torrent est construit à partir du localPath configuré
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
async function rdFetch(
config : Record<string, string | number>,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
): Promise<any> {
const res = await fetch(`${RD_BASE}${path}`, {
method,
headers: rdHeaders(config),
body : body,
})
if (res.status === 204) return null
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
}
return res.json()
}
function mapStatus(status: string): TorrentInfo['state'] {
switch (status) {
case 'downloaded': return 'seeding'
case 'downloading':
case 'queued':
case 'compressing':
case 'uploading':
case 'magnet_conversion': return 'downloading'
case 'waiting_files_selection': return 'paused'
case 'magnet_error':
case 'error':
case 'virus':
case 'dead': return 'error'
default: return 'unknown'
}
}
const rdDriver: TorrentClientDriver = {
definition: {
id : 'real-debrid',
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
const isMagnet = url.startsWith('magnet:')
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
// Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
new URLSearchParams({ files: String(fileId) }))
}
}
return
}
} catch {}
}
let torrentId: string
if (isMagnet) {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
} else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
method : 'PUT',
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
}
// Sélection des fichiers
if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
files = info.files
break
}
if (info.status === 'downloading' || info.status === 'downloaded') break
} catch {}
}
if (files.length > 0 && options.file_index < files.length) {
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
}
} else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection') {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
break
}
if (info.status !== 'magnet_conversion') break
} catch {}
}
}
},
async list(config, _category?) {
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? progress : 0,
}))
: undefined
return {
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
name : String(t.filename ?? t.id ?? ''),
state : mapStatus(t.status ?? ''),
progress : Math.round(progress * 100),
size : typeof t.bytes === 'number' ? t.bytes : 0,
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
uploaded : 0,
ratio : 0,
speed : typeof t.speed === 'number' ? t.speed : 0,
upspeed : 0,
eta : -1,
save_path,
category : '',
files,
}
})
},
async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
},
async getFiles(config, hash) {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
if (!Array.isArray(info.files)) return []
return info.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? overallProgress : 0,
}))
},
}
export default rdDriver
+3 -2
View File
@@ -244,7 +244,7 @@ const RT: TorrentClientDriver = {
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
try {
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes='])
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
@@ -277,7 +277,8 @@ const RT: TorrentClientDriver = {
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0,
name : String(f[2] ?? ''),
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
}))
: undefined,
} satisfies TorrentInfo
+2 -1
View File
@@ -161,7 +161,8 @@ const TR: TorrentClientDriver = {
files : Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0,
name : String(f.name ?? ''),
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
}))
: undefined,
} satisfies TorrentInfo))
+15 -3
View File
@@ -9,9 +9,9 @@
<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]">
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="serie.poster_image" :src="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" />
@@ -47,6 +47,18 @@
>
{{ serie.plot }}
</p>
<!-- Lien wiki -->
<a
v-if="serie.wiki"
:href="serie.wiki"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
>
<ExternalLink :size="12" />
Wiki
</a>
</div>
</div>
</div>
@@ -55,7 +67,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv } from 'lucide-vue-next'
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
defineProps<{ serie: any }>()
const expanded = ref(false)
+60 -2
View File
@@ -149,7 +149,7 @@
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">
<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 -->
@@ -175,6 +175,18 @@
<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>
@@ -247,7 +259,7 @@
<!-- 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">{{ ep.plot }}</p>
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
</div>
</div>
</div>
@@ -511,6 +523,52 @@ function epNeedsRename(ep: any): boolean {
return !!expected && expected !== entry.dest_filename
}
// ── 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[] = [