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
+82 -47

No files matched your search

+3 -1
View File
@@ -37,4 +37,6 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
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 ─────────────────────────────────────────────────
FROM node:22-slim
ARG BUILD_VERSION=dev
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -25,6 +26,7 @@ COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt
EXPOSE 3001
ENV NODE_ENV=production
+6 -7
View File
@@ -353,7 +353,7 @@ async function organizeTorrent(
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Skip (incomplet / priorité 0) : ${filename}`)
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 } }
if (!src) {
error(`Fichier source introuvable : ${filename}`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
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 (chemins essayés : ${candidates.join(', ')})` }] }
}
if (!fs.existsSync(dest)) {
@@ -418,9 +418,9 @@ async function organizeTorrent(
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).
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
warn(`Skip (incomplet / priorité 0) : ${filename}`)
result.skipped++
continue
}
@@ -436,8 +436,7 @@ async function organizeTorrent(
if (!src) {
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
// On skip silencieusement — pas une erreur.
debug(`Fichier non disponible (skip) : ${filename}`)
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++
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 orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
let serieName: string | undefined
let episodes: any[] = []
try {
const availableIds = await readAvailable()
for (const id of availableIds) {
const sd = await readSerieData(id)
if (!sd) continue
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 {}
const doneFiles = Object.keys(orgFiles).length
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
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)
} catch (err) {
+7 -1
View File
@@ -32,7 +32,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
}
const { mediaPath, organizeMode, nfoSupport } = readSettings()
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 }
const rawTitle = sd.title ?? sd.show_title ?? ''
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,
* applique les priorités, puis reprend le torrent.
* puis applique les priorités (désactive tout sauf la cible).
*/
async function qbApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
resume : boolean,
): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
@@ -118,36 +117,13 @@ async function qbApplyFilePriority(
}
}
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
// 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)}…`)
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (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)}…`)
}
@@ -275,7 +251,7 @@ const QB: TorrentClientDriver = {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
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}`)
)
return
@@ -305,14 +281,12 @@ const QB: TorrentClientDriver = {
form.append('urls', url)
} else {
// 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)}`)
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}`)
const torrentBytes = await torrentRes.arrayBuffer()
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`, {
@@ -322,8 +296,8 @@ const QB: TorrentClientDriver = {
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
@@ -341,7 +315,7 @@ const QB: TorrentClientDriver = {
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
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
} catch {}
}
+47 -3
View File
@@ -4,7 +4,30 @@
<!-- Nom + état -->
<div class="flex items-start justify-between gap-4">
<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">
<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>
@@ -65,8 +88,12 @@
: 'Non importé' }}
</span>
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
{{ importing ? '...' : 'Importer' }}
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
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>
</template>
@@ -130,6 +157,23 @@ const withFiles = ref(false)
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) {
return ({
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },