diff --git a/server/index.ts b/server/index.ts index 8f8418c..a3b1090 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,6 @@ import express from 'express' import cookieParser from 'cookie-parser' +import http from 'http' import fs from 'fs' import path from 'path' import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js' @@ -9,8 +10,9 @@ import { listClients, addClient, removeClient, getClient, sanitizeClient, dispatchDownload, dispatchList, updateClient } from './torrent-clients/index.js' -import qbittorrentDriver from './torrent-clients/qbittorrent.js' +import qbittorrentDriver from './torrent-clients/qbittorrent.js' import transmissionDriver from './torrent-clients/transmission.js' +import synologyDsDriver from './torrent-clients/synology-ds.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' @@ -18,6 +20,7 @@ import { systemInfo } from './system.js' registerDriver(qbittorrentDriver) registerDriver(transmissionDriver) +registerDriver(synologyDsDriver) const app = express() const PORT = Number(process.env.PORT) || 9898 @@ -84,7 +87,7 @@ app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => { if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return } const driver = getDriver(type) if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } - + if (uuid) { const oldClient = getClient(uuid) if (oldClient) { @@ -526,12 +529,186 @@ app.get('/api/debug/stats', requireAuth, (req, res) => { }) }) +// ── NFO Updates ──────────────────────────────────────────────── + +interface NfoUpdateNotif { + serieTitle : string + commitSha : string + commitMsg : string + updatedAt : string + status : 'updated' | 'error' + error? : string +} + +const recentNfoUpdates: NfoUpdateNotif[] = [] +const MAX_NFO_NOTIFS = 30 +const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json') + +const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository' +const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' + +const SERIE_TITLE_GITLAB_MAP: Record = { + 'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', + 'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū', + 'Hikaru No Go Henshū' : 'Hikaru no Go Henshū', + 'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï', + 'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū', + 'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū', + 'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū', + 'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū', + 'Tower Of God Henshū' : 'Tower of God Henshū', +} + +function getGitlabTitle(title: string): string { + return SERIE_TITLE_GITLAB_MAP[title] ?? title +} + +function loadNfoCommits(): Record { + try { + if (!fs.existsSync(NFO_COMMITS_PATH)) return {} + return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) + } catch { return {} } +} + +function saveNfoCommits(commits: Record) { + fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true }) + fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8') +} + +async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> { + try { + const url = `${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main` + const res = await fetch(url, { headers: { 'User-Agent': 'fankarr' } }) + if (!res.ok) return null + const data: any[] = await res.json() + if (!data.length) return null + return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' } + } catch { return null } +} + +async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise { + let files: any[] = [] + let page = 1 + while (true) { + const batch = await fetch( + `${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, + { headers: { 'User-Agent': 'fankarr' } } + ).then(r => r.ok ? r.json() : []) + if (!Array.isArray(batch) || batch.length === 0) break + files.push(...batch) + if (batch.length < 100) break + page++ + } + + const blobs = files.filter((f: any) => f.type === 'blob') + let downloaded = 0, skipped = 0 + + for (const entry of blobs) { + const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') + const destPath = path.join(destRoot, relativePath) + try { + const rawUrl = `${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` + const res = await fetch(rawUrl, { headers: { 'User-Agent': 'fankarr' } }) + if (!res.ok) { skipped++; continue } + fs.mkdirSync(path.dirname(destPath), { recursive: true }) + fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer())) + downloaded++ + } catch { skipped++ } + } + logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`) +} + +async function checkNfoUpdates() { + const { mediaPath, nfoSupport } = readSettings() + if (!nfoSupport) return + + let organized: Record> = {} + try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} + if (Object.keys(organized).length === 0) return + + // Récupérer les titres de séries qu'on a déjà importées + const seriesData = await loadEnrichedSeriesData() + const knownHashes = new Set(Object.keys(organized)) + const serieTitles = new Set() + + for (const sd of seriesData) { + const allTorrents = [ + ...(sd.torrents ?? []), + ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), + ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? [])), + ] + if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) { + const rawTitle = sd.title ?? sd.show_title ?? '' + if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()) + } + } + + if (serieTitles.size === 0) return + + const commits = loadNfoCommits() + let hasChanges = false + + for (const serieTitle of serieTitles) { + const gitlabTitle = getGitlabTitle(serieTitle) + const latest = await getLatestGitlabCommit(gitlabTitle) + if (!latest) continue + + const known = commits[gitlabTitle] + if (known === latest.sha) continue + + // Nouveau commit détecté → re-télécharger + logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`) + try { + const destRoot = path.join(mediaPath, serieTitle) + await downloadNfoFolder(gitlabTitle, destRoot) + commits[gitlabTitle] = latest.sha + hasChanges = true + + recentNfoUpdates.unshift({ + serieTitle, + commitSha : latest.sha.slice(0, 8), + commitMsg : latest.message, + updatedAt : new Date().toISOString(), + status : 'updated', + }) + if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop() + } catch (err) { + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`) + recentNfoUpdates.unshift({ + serieTitle, + commitSha : latest.sha.slice(0, 8), + commitMsg : latest.message, + updatedAt : new Date().toISOString(), + status : 'error', + error : msg, + }) + if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop() + } + } + + if (hasChanges) saveNfoCommits(commits) +} + +app.get('/api/nfo-updates/recent', requireAuth, (_req, res) => { + res.json(recentNfoUpdates) +}) + +app.post('/api/nfo-updates/check', requireAuth, async (_req, res) => { + logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée') + checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`)) + res.json({ ok: true, message: 'Vérification lancée en arrière-plan' }) +}) + if (fs.existsSync(PUBLIC_PATH)) { app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) }) } // ── Démarrage ────────────────────────────────────────────────── -app.listen(PORT, async () => { +// FIX : maxHeaderSize à 32KB pour éviter l'erreur 431 (cookies trop volumineux, Firefox) +const server = http.createServer({ maxHeaderSize: 32768 }, app) + +server.listen(PORT, async () => { logger.info('api', `Serveur démarré sur le port ${PORT}`) try { @@ -569,4 +746,12 @@ app.listen(PORT, async () => { autoOrganize() setInterval(autoOrganize, 5 * 60_000) }, 10_000) + + // Vérification NFO GitLab toutes les heures + setTimeout(() => { + checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`)) + setInterval(() => { + checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`)) + }, 60 * 60_000) + }, 30_000) // 30s après le démarrage pour ne pas surcharger }) \ No newline at end of file diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 146c56d..ac873ea 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -103,30 +103,6 @@ export function addClient(name: string, type: string, config: Record): SavedClient | null { - const clients = loadClients() - const index = clients.findIndex(c => c.uuid === uuid) - if (index === -1) return null - - const oldClient = clients[index] - const definition = getDriver(type)?.definition - - // Conserver les mots de passe si "••••••••" est envoyé - if (definition) { - for (const field of definition.fields) { - if (field.type === 'password' && config[field.key] === '••••••••') { - config[field.key] = oldClient.config[field.key] - } - } - } - - const client: SavedClient = { uuid, name, type, config } - clients[index] = client - saveClients(clients) - logger.info('torrent-clients', `Client mis à jour : "${name}" (${type})`) - return client -} - export function removeClient(uuid: string): boolean { const clients = loadClients() const target = clients.find(c => c.uuid === uuid) @@ -141,6 +117,16 @@ export function getClient(uuid: string): SavedClient | undefined { return loadClients().find(c => c.uuid === uuid) } +export function updateClient(uuid: string, name: string, type: string, config: Record): SavedClient | null { + const clients = loadClients() + const index = clients.findIndex(c => c.uuid === uuid) + if (index === -1) return null + clients[index] = { ...clients[index], name, type, config } + saveClients(clients) + logger.info('torrent-clients', `Client modifié : "${name}" (${type})`) + return clients[index] +} + export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient { if (!definition) definition = getDriver(client.type)?.definition if (!definition) return client @@ -153,6 +139,19 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe return { ...client, config } } +// ─── Remote path remapping ──────────────────────────────────────────────────── + +// FIX : remplace le préfixe remotePath par localPath dans save_path +// ex: remotePath=/downloads, localPath=/mnt/nas/downloads +// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie +function remapSavePath(savePath: string, remotePath: string, localPath: string): string { + const remote = remotePath.replace(/\/+$/, '') + const local = localPath.replace(/\/+$/, '') + if (!remote || !local) return savePath + if (!savePath.startsWith(remote)) return savePath + return local + savePath.slice(remote.length) +} + // ─── Dispatch download ───────────────────────────────────────────────────────── export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> { @@ -198,8 +197,22 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & { try { const clientCategory = (client.config.category as string) || category const torrents = await driver.list(client.config, clientCategory) + + const remotePath = String(client.config.remotePath ?? '') + const localPath = String(client.config.localPath ?? '') + const hasRemap = remotePath && localPath + for (const t of torrents) { - results.push({ ...t, client_uuid: client.uuid, client_name: client.name }) + // FIX : remap save_path si remotePath et localPath sont configurés + const save_path = hasRemap + ? remapSavePath(t.save_path, remotePath, localPath) + : t.save_path + + if (hasRemap && save_path !== t.save_path) { + logger.debug('torrent-clients', `Remap save_path : "${t.save_path}" → "${save_path}"`) + } + + results.push({ ...t, save_path, client_uuid: client.uuid, client_name: client.name }) } } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 1ceee1f..4052d35 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -39,10 +39,13 @@ const QB: TorrentClientDriver = { id : 'qbittorrent', label : 'qBittorrent', fields: [ - { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, - { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, - { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, - { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, + { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, + { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, + { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, + { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, + { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, ], }, @@ -110,7 +113,7 @@ const QB: TorrentClientDriver = { }) const text = await res.text() if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`) - logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`) + logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, } diff --git a/server/torrent-clients/synology-ds.ts b/server/torrent-clients/synology-ds.ts new file mode 100644 index 0000000..0178925 --- /dev/null +++ b/server/torrent-clients/synology-ds.ts @@ -0,0 +1,158 @@ +/** + * Synology Download Station Driver + */ + +import type { TorrentClientDriver, TorrentInfo } from './index.js' +import { logger } from '../logger.js' + +// Synology Download Station task status codes +function mapState(status: string): TorrentInfo['state'] { + if (status === 'downloading') return 'downloading' + if (status === 'seeding' || status === 'finished') return 'seeding' + if (status === 'paused') return 'paused' + if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading' + if (status === 'hash_checking') return 'checking' + if (status === 'error') return 'error' + return 'unknown' +} + +// Authentification DSM — retourne un SID +async function dsLogin(config: Record): Promise { + const params = new URLSearchParams({ + api : 'SYNO.API.Auth', + version: '3', + method : 'login', + account: String(config.username ?? ''), + passwd : String(config.password ?? ''), + session: 'DownloadStation', + format : 'sid', + }) + + const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`) + return data.data.sid +} + +async function dsRequest( + config : Record, + api : string, + method : string, + version: string, + extra : Record = {}, + sid : string, +): Promise { + const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra }) + const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`) + return data.data +} + +const DS: TorrentClientDriver = { + definition: { + id : 'synology-ds', + label : 'Synology Download Station', + fields: [ + { key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true }, + { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, + { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, + { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false }, + { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false }, + { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, + ], + }, + + async test(config) { + try { + await dsLogin(config) + logger.info('synology-ds', `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('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`) + return { ok: false, message: msg } + } + }, + + async healthcheck(config) { + try { + const sid = await dsLogin(config) + const params = new URLSearchParams({ + api : 'SYNO.DownloadStation.Info', + version: '2', + method : 'getinfo', + _sid : sid, + }) + const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`) + if (!res.ok) return { online: false } + const data = await res.json() + if (!data.success) return { online: false } + const version = data.data?.version_string ?? 'inconnue' + logger.debug('synology-ds', `Healthcheck OK — version ${version}`) + return { online: true, version } + } catch (err) { + logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) + return { online: false } + } + }, + + async list(config, category) { + const sid = await dsLogin(config) + const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', + { additional: 'transfer,detail' }, sid) + + const tasks: any[] = data?.tasks ?? [] + + return tasks + .filter(t => { + if (!category) return true + // Synology n'a pas de labels natifs — on filtre par destination si elle contient la catégorie + return (t.additional?.detail?.destination as string)?.includes(String(category)) + }) + .map(t => { + const transfer = t.additional?.transfer ?? {} + const size = t.size ?? 0 + const dl = transfer.size_downloaded ?? 0 + return { + hash : t.id, + name : t.title, + state : mapState(t.status), + progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0, + size, + downloaded: dl, + speed : transfer.speed_download ?? 0, + eta : -1, + save_path : t.additional?.detail?.destination ?? '', + category : '', + } satisfies TorrentInfo + }) + }, + + async add(config, url) { + const sid = await dsLogin(config) + + const extra: Record = { uri: url } + if (config.savePath) extra.destination = String(config.savePath) + + const params = new URLSearchParams({ + api : 'SYNO.DownloadStation.Task', + version: '1', + method : 'create', + _sid : sid, + ...extra, + }) + + const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`) + + logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`) + }, +} + +export default DS \ No newline at end of file diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts index 35d216a..89e2c8b 100644 --- a/server/torrent-clients/transmission.ts +++ b/server/torrent-clients/transmission.ts @@ -57,10 +57,13 @@ const TR: TorrentClientDriver = { 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: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { 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: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, + { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, + { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false }, ], }, @@ -99,7 +102,6 @@ const TR: TorrentClientDriver = { return torrents .filter(t => { if (!category) return true - // Filtrer par label uniquement, comme qBittorrent filtre par catégorie return t.labels?.includes(category) }) .map(t => ({ @@ -128,7 +130,7 @@ const TR: TorrentClientDriver = { } await trRequest(config, 'torrent-add', args) - logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`) + logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) }, } diff --git a/src/views/settings/DownloadClientView.vue b/src/views/settings/DownloadClientView.vue index e905d33..e1e3619 100644 --- a/src/views/settings/DownloadClientView.vue +++ b/src/views/settings/DownloadClientView.vue @@ -67,7 +67,10 @@
- +