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:
1 parent
39463bfbcb
commit
2a77a38655
10 files changed
+454
-24
No files matched your search
@@ -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}`)
|
||||
|
||||
Reference in new issue
Block a user