diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 8403192..b809a5a 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -13,7 +13,6 @@ import { DATA_DIR } from './config.js' const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') // ─── Utils log ──────────────────────────────────────────────── -// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) } function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) } @@ -51,29 +50,23 @@ function isOrganized(hash: string, filename: string): boolean { function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null { const h = hash.toLowerCase() for (const sd of seriesData) { - // Niveau série (pack intégrale) for (const t of sd.torrents ?? []) { if (t.infohash?.toLowerCase() === h) return { torrent: t, serieData: sd } } - // Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons - // Si oui, pas de _season pour que buildFileMap scanne toutes les saisons const seasonsWithHash = (sd.seasons ?? []).filter((s: any) => s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h) ) if (seasonsWithHash.length === 1) { - // Torrent propre à une seule saison → on filtre sur cette saison const season = seasonsWithHash[0] const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h) return { torrent: { ...t, _season: season }, serieData: sd } } else if (seasonsWithHash.length > 1) { - // Même torrent dans plusieurs saisons → pas de filtre saison const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h) return { torrent: t, serieData: sd } } - // Niveau épisode for (const season of sd.seasons ?? []) { const matchingEpisodes = season.episodes?.filter((ep: any) => ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h) @@ -97,8 +90,9 @@ function buildFileMap( hash : string, nfoSupport : boolean, seasonFilter?: number, -): Map { - const map = new Map() +): Map { + // FIX : type de retour utilise nfo_filename au lieu de original_filename + const map = new Map() const h = hash.toLowerCase() for (const season of serieData.seasons ?? []) { @@ -124,17 +118,21 @@ function buildFileMap( : null if (!nfoSupport && !fmtName) { - warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`) + warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`) } - const destName = nfoSupport - ? (ep.original_filename ?? filename) - : (fmtName ?? ep.original_filename ?? filename) + // FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut + // On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv) + const srcExt = path.extname(filename) + const resolvedName = nfoSupport + ? (ep.nfo_filename ?? ep.original_filename ?? filename) + : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename) + const destName = swapExtension(resolvedName, srcExt) map.set(filename, { - season_number : season.season_number, - original_filename: destName, - fullPath : matchedPath, + season_number: season.season_number, + nfo_filename : destName, + fullPath : matchedPath, }) } } @@ -241,6 +239,15 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi } // ─── Filesystem ops ─────────────────────────────────────────── + +// FIX : remplace l'extension d'un nom de fichier par celle du fichier source +// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv" +function swapExtension(filename: string, ext: string): string { + const currentExt = path.extname(filename) + if (!currentExt || currentExt === ext) return filename + return filename.slice(0, -currentExt.length) + ext +} + function seasonFolder(n: number): string { return n === 0 ? 'Specials' : `Saison ${n}` } @@ -296,9 +303,15 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null - const destName = nfoSupport - ? (ep.original_filename ?? filename) - : (fmtName ?? ep.original_filename ?? filename) + + // FIX : priorité à nfo_filename pour épisode unique aussi + // On swap l'extension (.nfo → extension du fichier source) + const srcExt = path.extname(filename) + const resolvedName = nfoSupport + ? (ep.nfo_filename ?? ep.original_filename ?? filename) + : (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename) + const destName = swapExtension(resolvedName, srcExt) + const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) const dest = path.join(destDir, destName) @@ -347,7 +360,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser result.total = fileMap.size - for (const [filename, { season_number, original_filename, fullPath }] of fileMap) { + // FIX : destructure nfo_filename au lieu de original_filename + for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) { if (isOrganized(hash, filename)) { result.skipped++; continue } const candidates = [ @@ -366,7 +380,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser } const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number)) - const dest = path.join(destDir, original_filename) + const dest = path.join(destDir, nfo_filename) if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue } @@ -380,7 +394,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser } markOrganized(hash, filename) result.done++ - debug(`${original_filename} → Saison ${season_number}`) + debug(`${nfo_filename} → Saison ${season_number}`) } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' error(`Échec import "${filename}" : ${msg}`) diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index 675dc27..35d216a 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -23,7 +23,7 @@ async function trRequest( sessionId = '' ): Promise<{ result: string; arguments?: any; sessionId?: string }> { const headers: Record = { - 'Content-Type': 'application/json', + 'Content-Type' : 'application/json', 'X-Transmission-Session-Id': sessionId, } @@ -60,7 +60,7 @@ const TR: TorrentClientDriver = { { key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true }, { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false }, { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false }, - { key: 'category', label: 'Dossier cible',type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, ], }, @@ -89,17 +89,18 @@ const TR: TorrentClientDriver = { }, async list(config, category) { - const fields = ['hashString', 'name', 'status', 'percentDone', 'totalSize', - 'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels'] + const fields = [ + 'hashString', 'name', 'status', 'percentDone', 'totalSize', + 'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels', + ] const data = await trRequest(config, 'torrent-get', { fields }) const torrents: any[] = data.arguments?.torrents ?? [] return torrents .filter(t => { if (!category) return true - // Filtrer par label ou par dossier contenant la catégorie - return t.labels?.includes(category) || - (t.downloadDir as string)?.includes(category) + // Filtrer par label uniquement, comme qBittorrent filtre par catégorie + return t.labels?.includes(category) }) .map(t => ({ hash : t.hashString, @@ -120,9 +121,6 @@ const TR: TorrentClientDriver = { if (config.savePath) { args['download-dir'] = String(config.savePath) - } else if (config.category) { - // Si pas de savePath explicite, ajouter la catégorie comme sous-dossier - args['download-dir'] = `/sdcard/Download/${config.category}` } if (config.category) { diff --git a/src/views/DownloadsView.vue b/src/views/DownloadsView.vue index f8ccc7a..d49a03c 100644 --- a/src/views/DownloadsView.vue +++ b/src/views/DownloadsView.vue @@ -159,7 +159,7 @@
-

{{ t.progress }}%

+

{{ clampedProgress(t) }}%

{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}

@@ -168,10 +168,10 @@
@@ -181,9 +181,10 @@ ↓ {{ formatSpeed(t.speed) }} ETA {{ formatEta(t.eta) }} Complété + État non remonté par le client
-
+
@@ -275,12 +276,13 @@ function setSort(val: typeof activeSort.value) { let pollInterval: ReturnType | null = null +// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents const activeTorrents = computed(() => - torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state)) + torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state)) ) const doneTorrents = computed(() => - torrents.value.filter(t => t.state === 'seeding') + torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown') ) const visibleTorrents = computed(() => { @@ -320,6 +322,11 @@ const stats = computed(() => [ { label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length }, ]) +// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100% +function clampedProgress(t: any): number { + return Math.min(100, Math.max(0, t.progress ?? 0)) +} + async function fetchTorrents() { try { const res = await fetch('/api/downloads', { credentials: 'include' }) @@ -406,7 +413,8 @@ function stateBadge(state: string) { paused : { label: 'En pause', class: 'border-border text-muted' }, checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' }, error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' }, - unknown : { label: 'Inconnu', class: 'border-border text-muted' }, + // FIX : badge jaune pour signaler état dégradé sans alarmer + unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' }, } return map[state] ?? map.unknown } diff --git a/src/views/settings/MediaManagementView.vue b/src/views/settings/MediaManagementView.vue index 5a92066..ea4c7fc 100644 --- a/src/views/settings/MediaManagementView.vue +++ b/src/views/settings/MediaManagementView.vue @@ -15,10 +15,13 @@ Docker
-

Dossier où sont déposés les fichiers téléchargés

+

+ ⚠ Chemin non configuré — cliquez pour sélectionner un dossier +

+

Dossier où sont déposés les fichiers téléchargés

@@ -27,14 +30,29 @@ Docker
-

Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)

+

+ ⚠ Chemin non configuré — cliquez pour sélectionner un dossier +

+

Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)

+ +
+ ⚠ +
+

Chemins non configurés

+

+ L'import automatique est désactivé tant que les dossiers ne sont pas configurés. + Configurez-les ci-dessus avant de sauvegarder. +

+
+
+
@@ -109,7 +127,7 @@