feat(UI/UX): affichage de la serie et des episodes sur la page download du torrent

fix: les torrents n'utilise plus la pause
feat(log): + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher)
fix(docker): ajout du fichier de versions pour la navbar dans docker lors du build
fix(import): les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper
fix(UI/UX): retour visuel lors de l'import d'une serie/episode
This commit is contained in:
MasutaYunikon committed 2026-05-10 19:20:01 +02:00
1 parent f81969cdd0
commit cb28baf25d
7 files changed
+81 -46

No files matched your search

+2
View File
@@ -38,3 +38,5 @@ jobs:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
+2
View File
@@ -16,6 +16,7 @@ RUN pnpm run build
# ── Production ───────────────────────────────────────────────── # ── Production ─────────────────────────────────────────────────
FROM node:22-slim FROM node:22-slim
ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -25,6 +26,7 @@ COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN mkdir -p /config RUN mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production ENV NODE_ENV=production
+6 -7
View File
@@ -353,7 +353,7 @@ async function organizeTorrent(
// Vérification de complétion pour épisode unique // Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) { if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`) warn(`Skip (incomplet / priorité 0) : ${filename}`)
return { ...result, total: 1, skipped: 1 } return { ...result, total: 1, skipped: 1 }
} }
@@ -371,8 +371,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) { if (!src) {
error(`Fichier source introuvable : ${filename}`) error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] } return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
} }
if (!fs.existsSync(dest)) { if (!fs.existsSync(dest)) {
@@ -418,9 +418,9 @@ async function organizeTorrent(
if (isOrganized(hash, episode_id)) { result.skipped++; continue } if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Vérification de complétion : si on a les infos de progression par fichier, // 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). // on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) { if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`) warn(`Skip (incomplet / priorité 0) : ${filename}`)
result.skipped++ result.skipped++
continue continue
} }
@@ -436,8 +436,7 @@ async function organizeTorrent(
if (!src) { if (!src) {
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0). // Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
// On skip silencieusement — pas une erreur. warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
debug(`Fichier non disponible (skip) : ${filename}`)
result.skipped++ result.skipped++
continue continue
} }
+10 -2
View File
@@ -49,19 +49,27 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const enriched = await Promise.all(torrents.map(async (t: any) => { const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {} const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1 let totalFiles = 1
let serieName: string | undefined
let episodes: any[] = []
try { try {
const availableIds = await readAvailable() const availableIds = await readAvailable()
for (const id of availableIds) { for (const id of availableIds) {
const sd = await readSerieData(id) const sd = await readSerieData(id)
if (!sd) continue if (!sd) continue
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break } if (match) {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
serieName = sd.title ?? sd.show_title
episodes = resolved
break
}
} }
} catch {} } catch {}
const doneFiles = Object.keys(orgFiles).length const doneFiles = Object.keys(orgFiles).length
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none' const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
const notif = recentOrganized.find(n => n.hash === t.hash) const notif = recentOrganized.find(n => n.hash === t.hash)
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] } return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
})) }))
res.json(enriched) res.json(enriched)
} catch (err) { } catch (err) {
+7 -1
View File
@@ -32,7 +32,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} }
const { mediaPath, organizeMode, nfoSupport } = readSettings() const { mediaPath, organizeMode, nfoSupport } = readSettings()
const organized = loadOrganizedJson() const organized = loadOrganizedJson()
const sd = await readSerieData(Number(serie_id)) let sd = await readSerieData(Number(serie_id))
if (!sd) {
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
const all = await loadEnrichedSeriesData()
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
}
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return } if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
const rawTitle = sd.title ?? sd.show_title ?? '' const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim() const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
+7 -33
View File
@@ -58,13 +58,12 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
/** /**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible, * Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* applique les priorités, puis reprend le torrent. * puis applique les priorités (désactive tout sauf la cible).
*/ */
async function qbApplyFilePriority( async function qbApplyFilePriority(
config : Record<string, string | number>, config : Record<string, string | number>,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
resume : boolean,
): Promise<void> { ): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s // Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) { for (let attempt = 0; attempt < 60; attempt++) {
@@ -118,36 +117,13 @@ async function qbApplyFilePriority(
} }
} }
// Reprendre (torrent mis en pause via paused/stopped à l'ajout) logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
if (resume) {
const resumeBody = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
])
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
return return
} catch (err) { } catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err if (err instanceof Error && err.message.includes('hors limites')) throw err
} }
} }
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const authH = await qbAuth(config)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
const body = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
])
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`) throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
} }
@@ -275,7 +251,7 @@ const QB: TorrentClientDriver = {
const exists = await qbTorrentExists(config, authH, hash) const exists = await qbTorrentExists(config, authH, hash)
if (exists) { if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`) logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err => qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
) )
return return
@@ -305,14 +281,12 @@ const QB: TorrentClientDriver = {
form.append('urls', url) form.append('urls', url)
} else { } else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit // FanKarr télécharge le .torrent et l'envoie directement à qBit
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`) logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } }) const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`) if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer() const torrentBytes = await torrentRes.arrayBuffer()
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent') form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
form.append('paused', 'true') // qBit < 5.0
form.append('stopped', 'true') // qBit ≥ 5.0
} }
const res = await fetch(`${config.url}/api/v2/torrents/add`, { const res = await fetch(`${config.url}/api/v2/torrents/add`, {
@@ -322,8 +296,8 @@ const QB: TorrentClientDriver = {
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`) if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (hash) { if (hash) {
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`) 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 => qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`) logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
) )
} else { } else {
@@ -341,7 +315,7 @@ const QB: TorrentClientDriver = {
if (!newT) continue if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase() const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`) logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex, true) await qbApplyFilePriority(config, resolvedHash, fileIndex)
return return
} catch {} } catch {}
} }
+47 -3
View File
@@ -4,7 +4,30 @@
<!-- Nom + état --> <!-- Nom + état -->
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p> <!-- Nom du torrent + tooltip épisodes -->
<div class="relative group/eps inline-block max-w-full">
<p class="text-sm text-primary font-medium truncate cursor-default">{{ torrent.name }}</p>
<!-- Tooltip épisodes -->
<div v-if="episodesWithProgress.length > 0"
class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
<div class="bg-card border border-border rounded-lg p-3 text-xs shadow-xl">
<p class="text-muted font-medium mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<div v-for="ep in episodesWithProgress" :key="ep.episode_id"
class="flex items-center justify-between gap-3">
<span class="text-primary shrink-0">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
<span v-if="ep.progress !== null"
class="text-[10px] px-1.5 py-0.5 rounded shrink-0"
:class="ep.progress >= 100 ? 'bg-green-500/15 text-green-500' : ep.priority === 0 ? 'bg-border/50 text-muted' : 'bg-accent/15 text-accent'">
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress}%` }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Nom de la série -->
<p v-if="torrent.serieName" class="text-[11px] text-muted truncate mt-0.5">{{ torrent.serieName }}</p>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span> <span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span> <span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
@@ -65,8 +88,12 @@
: 'Non importé' }} : 'Non importé' }}
</span> </span>
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1"> <button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
{{ importing ? '...' : 'Importer' }} class="btn-secondary text-xs py-1 flex items-center gap-1.5 min-w-[72px] justify-center">
<svg v-if="importing" width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" fill="none" class="animate-spin shrink-0">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
{{ importing ? 'Import…' : 'Importer' }}
</button> </button>
</template> </template>
@@ -130,6 +157,23 @@ const withFiles = ref(false)
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0))) const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
const episodesWithProgress = computed(() => {
const eps: any[] = props.torrent.episodes ?? []
if (!eps.length) return []
return eps.map((ep: any) => {
const file = (props.torrent.files ?? []).find((f: any) => {
const basename = String(f.name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
return basename.toLowerCase() === ep.filename?.toLowerCase()
})
return {
...ep,
progress: file != null ? Math.round((file.progress ?? 0) * 100) : null,
priority: file?.priority ?? 1,
}
})
})
function stateBadge(state: string) { function stateBadge(state: string) {
return ({ return ({
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' }, downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },