fix: le driver de Transmission n'est plus basé sur android

fix: les torrent inconnue en états seront mis dans la catégorie terminée
feat: ajout d'un retour visuel si on a un path par defaut sur /
fix: utilisation de nfo_filename au lieu de original_filename
This commit is contained in:
MasutaYunikon committed 2026-04-08 15:07:59 +02:00
1 parent bc960ba585
commit f3a36af629
4 files changed
+91 -45

No files matched your search

+36 -22
View File
@@ -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<string, { season_number: number; original_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
): Map<string, { season_number: number; nfo_filename: string; fullPath: string }> {
// FIX : type de retour utilise nfo_filename au lieu de original_filename
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
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}`)
+8 -10
View File
@@ -23,7 +23,7 @@ async function trRequest(
sessionId = ''
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
const headers: Record<string, string> = {
'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) {
+15 -7
View File
@@ -159,7 +159,7 @@
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ t.progress }}%</p>
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
</div>
</div>
@@ -168,10 +168,10 @@
<div class="h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' ? 'bg-green-500' :
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${t.progress}%` }"
:style="{ width: `${clampedProgress(t)}%` }"
/>
</div>
@@ -181,9 +181,10 @@
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
<!-- Erreurs fichiers -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
@@ -275,12 +276,13 @@ function setSort(val: typeof activeSort.value) {
let pollInterval: ReturnType<typeof setInterval> | 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
}
+32 -6
View File
@@ -15,10 +15,13 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="form.completePath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
@@ -27,14 +30,29 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="form.mediaPath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
@@ -109,7 +127,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
@@ -135,6 +153,15 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
currentPath: '/',
})
// FIX : détection path non configuré (vide ou racine /)
function isRootPath(p: string): boolean {
return !p || p === '/'
}
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
}
@@ -153,7 +180,6 @@ onMounted(async () => {
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
})
async function save() {