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

+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}`)