From f304562f0192b963ca4804ffc7cc412a55f66f78 Mon Sep 17 00:00:00 2001 From: MasutaYunikon Date: Tue, 7 Apr 2026 19:04:26 +0200 Subject: [PATCH] feat: add transmission client feat: set defaultPath for any system --- server/index.ts | 4 + server/system.ts | 15 +++ server/torrent-clients/transmission.ts | 137 +++++++++++++++++++++ src/views/settings/MediaManagementView.vue | 6 +- 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 server/system.ts create mode 100644 server/torrent-clients/transmission.ts diff --git a/server/index.ts b/server/index.ts index ef9999d..033ac29 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,7 @@ import qbittorrentDriver from './torrent-clients/qbittorrent.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' import { DATA_DIR, BASE_DIR } from './config.js' +import { systemInfo } from './system.js' registerDriver(qbittorrentDriver) @@ -36,6 +37,9 @@ app.post('/api/auth/setup', authSetup) app.post('/api/auth/login', authLogin) app.post('/api/auth/logout', authLogout) +// system info +app.get('/api/system/info', systemInfo) + // ── Settings ─────────────────────────────────────────────────── app.get('/api/settings', requireAuth, (_req, res) => { res.json(readSettings()) diff --git a/server/system.ts b/server/system.ts new file mode 100644 index 0000000..ade7a68 --- /dev/null +++ b/server/system.ts @@ -0,0 +1,15 @@ +import fs from 'fs' +import os from 'os' +import type { Request, Response } from 'express' + +// GET /api/system/info +export function systemInfo(_req: Request, res: Response): void { + const isTermux = fs.existsSync('/data/data/com.termux') + const platform = os.platform() + + res.json({ + platform, + isTermux, + defaultPath: isTermux ? '/sdcard/' : '/', + }) +} \ No newline at end of file diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts new file mode 100644 index 0000000..675dc27 --- /dev/null +++ b/server/torrent-clients/transmission.ts @@ -0,0 +1,137 @@ +/** + * Transmission Driver + */ + +import type { TorrentClientDriver, TorrentInfo } from './index.js' +import { logger } from '../logger.js' + +function mapState(status: number): TorrentInfo['state'] { + // Transmission status codes : + // 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued, + // 4 = downloading, 5 = seed queued, 6 = seeding + if (status === 4 || status === 3) return 'downloading' + if (status === 6 || status === 5) return 'seeding' + if (status === 0) return 'paused' + if (status === 1 || status === 2) return 'checking' + return 'unknown' +} + +async function trRequest( + config: Record, + method: string, + args: Record = {}, + sessionId = '' +): Promise<{ result: string; arguments?: any; sessionId?: string }> { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Transmission-Session-Id': sessionId, + } + + if (config.username && config.password) { + headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`) + } + + const res = await fetch(`${config.url}/transmission/rpc`, { + method : 'POST', + headers, + body : JSON.stringify({ method, arguments: args }), + }) + + // Transmission renvoie 409 avec le session ID à la première requête + if (res.status === 409) { + const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? '' + if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409') + return trRequest(config, method, args, newSessionId) + } + + if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`) + + const data = await res.json() + if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`) + + return { ...data, sessionId } +} + +const TR: TorrentClientDriver = { + definition: { + id : 'transmission', + label : 'Transmission', + fields: [ + { 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' }, + ], + }, + + async test(config) { + try { + await trRequest(config, 'session-get') + logger.info('transmission', `Test de connexion réussi sur ${config.url}`) + return { ok: true, message: 'Connexion réussie' } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const data = await trRequest(config, 'session-get') + const version = data.arguments?.version ?? 'inconnue' + logger.debug('transmission', `Healthcheck OK — version ${version}`) + return { online: true, version } + } catch (err) { + logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) + return { online: false } + } + }, + + async list(config, category) { + 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) + }) + .map(t => ({ + hash : t.hashString, + name : t.name, + state : mapState(t.status), + progress : Math.round(t.percentDone * 100), + size : t.totalSize, + downloaded: t.downloadedEver, + speed : t.rateDownload, + eta : t.eta ?? -1, + save_path : t.downloadDir, + category : t.labels?.[0] ?? '', + } satisfies TorrentInfo)) + }, + + async add(config, url) { + const args: Record = { filename: url } + + 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) { + args['labels'] = [String(config.category)] + } + + await trRequest(config, 'torrent-add', args) + logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`) + }, +} + +export default TR \ No newline at end of file diff --git a/src/views/settings/MediaManagementView.vue b/src/views/settings/MediaManagementView.vue index 2273ce9..8127e48 100644 --- a/src/views/settings/MediaManagementView.vue +++ b/src/views/settings/MediaManagementView.vue @@ -129,8 +129,12 @@ const form = ref({ autoImport : true, }) +const { data: systemInfo } = await useFetch('/api/system/info') + const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({ - open: false, field: 'completePath', currentPath: '/', + open: false, + field: 'completePath', + currentPath: systemInfo.value?.defaultPath ?? '/', }) function openPicker(field: 'completePath' | 'mediaPath') {