feat: ajout des parametres dossier de téléchargements pour les clients torrent

feat: ajout de synology downloader comme client

fix: augmentation du header size 8kb -> 32kb

feat: Ajout des mapping sur les clients

feat: auto update des fichiers nfo
This commit is contained in:
MasutaYunikon committed 2026-04-08 19:25:19 +02:00
1 parent 482743bf3a
commit d563ed4c68
6 files changed
+404 -40

No files matched your search

+188 -3
View File
@@ -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<string, string> = {
'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<string, string> {
try {
if (!fs.existsSync(NFO_COMMITS_PATH)) return {}
return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8'))
} catch { return {} }
}
function saveNfoCommits(commits: Record<string, string>) {
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<void> {
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<string, Record<string, string>> = {}
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<string>()
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
})
+38 -25
View File
@@ -103,30 +103,6 @@ export function addClient(name: string, type: string, config: Record<string, str
return client
}
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): 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<string, string | number>): 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'
+8 -5
View File
@@ -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}` : ''})`)
},
}
+158
View File
@@ -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<string, string | number>): Promise<string> {
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<string, string | number>,
api : string,
method : string,
version: string,
extra : Record<string, string> = {},
sid : string,
): Promise<any> {
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<string, string> = { 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
+8 -6
View File
@@ -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}` : ''})`)
},
}
+4 -1
View File
@@ -67,7 +67,10 @@
</select>
</div>
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
<label class="settings-label">{{ field.label }}</label>
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"