feat: initial project

This commit is contained in:
MasutaYunikon committed 2026-03-10 22:36:34 +01:00
commit eaf01cfc59
38 files changed
+5807

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs'
import path from 'path'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
const JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
const SALT_ROUNDS = 10
interface AuthData {
username: string
passwordHash: string
}
// ── Helpers fichier ───────────────────────────────────────────
function readAuth(): AuthData | null {
try {
if (!fs.existsSync(DATA_PATH)) return null
return JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'))
} catch { return null }
}
function writeAuth(data: AuthData): void {
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2))
}
// ── Routes ────────────────────────────────────────────────────
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const existing = readAuth()
const token = req.cookies?.fankarr_token
let loggedIn = false
if (token) {
try {
jwt.verify(token, JWT_SECRET)
loggedIn = true
} catch {}
}
res.json({
setup: !!existing,
loggedIn
})
}
// POST /api/auth/setup
export function authSetup(req: Request, res: Response): void {
if (readAuth()) {
res.status(400).json({ error: 'Un compte existe déjà' })
return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
}
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
// POST /api/auth/login
export function authLogin(req: Request, res: Response): void {
const auth = readAuth()
if (!auth) {
res.status(400).json({ error: 'Aucun compte configuré' })
return
}
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' })
return
}
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
res.status(401).json({ error: 'Identifiants incorrects' })
return
}
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
// POST /api/auth/logout
export function authLogout(_req: Request, res: Response): void {
res.clearCookie('fankarr_token')
res.json({ success: true })
}
// Middleware protection des routes
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
res.status(401).json({ error: 'Non authentifié' })
return
}
try {
jwt.verify(token, JWT_SECRET)
next()
} catch {
res.status(401).json({ error: 'Token invalide' })
}
}
+312
View File
@@ -0,0 +1,312 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import fs from 'fs'
import path from 'path'
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
import { readSettings, writeSettings } from './settings.js'
import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll } from './organize.js'
registerDriver(qbittorrentDriver)
const app = express()
const PORT = 3001
const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
app.use(express.json())
app.use(cookieParser())
// ── Static frontend (prod) ─────────────────────────────────────
const PUBLIC_PATH = path.join(process.cwd(), 'public')
if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
// ── Auth ───────────────────────────────────────────────────────
app.get('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
// ── Settings ───────────────────────────────────────────────────
app.get('/api/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
})
// ── Torrent clients ────────────────────────────────────────────
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
res.json(getAvailableClients())
})
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map(c => sanitizeClient(c)))
})
app.post('/api/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid))
if (!ok) res.status(404).json({ error: 'Client introuvable' })
else res.json({ ok: true })
})
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config } = req.body
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 }
res.json(await driver.test(config))
})
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.test(client.config))
})
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.healthcheck(client.config))
})
// ── Helpers ────────────────────────────────────────────────────
function readTorrents(): any[] {
try {
if (!fs.existsSync(TORRENTS_PATH)) return []
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
} catch { return [] }
}
async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json()
}
function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>()
for (const t of torrents) {
if (!t.serie_id) continue
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
map.get(t.serie_id)!.push(t)
}
return map
}
// ── Series ─────────────────────────────────────────────────────
app.get('/api/series', requireAuth, async (_req, res) => {
try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
const byId = indexBySerie(torrents)
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
res.json({ series: seriesList.map((serie: any) => ({
...serie,
torrent_count: byId.get(serie.id)?.length ?? 0,
has_torrents : byId.has(serie.id)
}))})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
app.get('/api/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
}))
const torrents = readTorrents().filter(t => t.serie_id === id)
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap: Record<number, any> = {}
const seasonTorrentMap : Record<number, any> = {}
const integraleTorrents: any[] = []
const packEpisodesTorrents: any[] = []
for (const t of torrents) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
for (const ep of t.resolved_episodes ?? []) {
availableEpisodeIds.add(ep.episode_id)
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
}
if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? []
if (rs.length > 1) {
packEpisodesTorrents.push(t)
} else {
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
}
} else if (t.type === 'pack_integrale') {
integraleTorrents.push(t)
} else if (t.type === 'pack_episodes') {
packEpisodesTorrents.push(t)
}
}
let promotedIntegrale: any = null
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
const rs: any[] = t.resolved_seasons ?? []
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
}
}
if (integraleTorrents.length > 0 || promotedIntegrale) {
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
if (promotedIntegrale) {
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
}
}
function packEpisodesLabel(t: any): string {
const eps: any[] = t.resolved_episodes ?? []
if (!eps.length) return t.raw
const bySeason = new Map<number, number[]>()
for (const ep of eps) {
const sn = ep.season_number ?? 0
if (!bySeason.has(sn)) bySeason.set(sn, [])
bySeason.get(sn)!.push(ep.episode_number)
}
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
const s = nums.sort((a, b) => a - b)
const label = sn === 0 ? 'S0' : `S${sn}`
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
}).join(' · ')
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
...season,
torrent: seasonTorrentMap[season.id] ?? null,
episodes: season.episodes.map((ep: any) => ({
...ep,
available: availableEpisodeIds.has(ep.id),
torrent : episodeTorrentMap[ep.id] ?? null
}))
}))
const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
]
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Download ───────────────────────────────────────────────────
app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.torrent_url ?? req.body.magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const results = await dispatchDownload(url)
res.status(results.every(r => r.ok) ? 200 : 207).json({ results })
})
// ── Downloads — liste torrents Fankai ──────────────────────────
app.get('/api/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai')
res.json(torrents)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Organize ───────────────────────────────────────────────────
app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) {
res.status(400).json({ error: 'hash, name et save_path requis' })
return
}
try {
const result = await organizeTorrent(hash, name, save_path)
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Torrents status ────────────────────────────────────────────
app.get('/api/torrents/status', requireAuth, (_req, res) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
const data = JSON.parse(raw)
const count = Array.isArray(data) ? data.length : 0
res.json({ exists: true, count, empty: count === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
// ── Update torrent_final.json ──────────────────────────────────
app.post('/api/update', requireAuth, async (_req, res) => {
try {
const response = await fetch(GITHUB_RAW_URL)
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
const data = await response.json() as unknown[]
if (!Array.isArray(data)) throw new Error('Format invalide')
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
res.json({ ok: true, count: data.length })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── SPA fallback (prod) — doit être après toutes les routes API ─
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => {
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
})
}
// ── Start ──────────────────────────────────────────────────────
app.listen(PORT, () => {
try {
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
const count = Array.isArray(data) ? data.length : 0
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
else console.log(`[fankarr] ${count} torrents chargés`)
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
// Auto-organise toutes les 30s
const autoOrganize = async () => {
try {
const { category } = readSettings()
await autoOrganizeAll(() => dispatchList(category ?? 'fankai'))
} catch (err) {
console.error('[organize] Erreur auto-organise:', err)
}
}
// Premier run 30s après démarrage, puis toutes les 30s
setTimeout(() => {
autoOrganize()
setInterval(autoOrganize, 30_000)
}, 30_000)
})
+248
View File
@@ -0,0 +1,248 @@
/**
* Organizer
* =========
* Déplace ou hardlinke les fichiers téléchargés vers la médiathèque Jellyfin.
* Incrémental : skip les fichiers déjà organisés.
*
* Structure dest :
* mediaPath / serie_title / Saison 001 / filename.mkv
*/
import fs from 'fs'
import path from 'path'
import { readSettings } from './settings.js'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function saveOrganized(data: Record<string, Record<string, string>>) {
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function markOrganized(hash: string, filename: string) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
saveOrganized(data)
}
function isOrganized(hash: string, filename: string): boolean {
const data = loadOrganized()
return !!data[hash]?.[filename]
}
// ─── Torrent lookup ───────────────────────────────────────────
function readTorrents(): any[] {
try {
if (!fs.existsSync(TORRENTS_PATH)) return []
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
} catch { return [] }
}
function findTorrentByName(name: string): any | null {
const torrents = readTorrents()
return torrents.find(t => t.raw === name) ?? null
}
// ─── Filesystem ops ───────────────────────────────────────────
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true })
}
function seasonFolder(n: number): string {
return `Saison ${String(n).padStart(3, '0')}`
}
function tryHardlink(src: string, dest: string): boolean {
try {
fs.linkSync(src, dest)
return true
} catch {
return false
}
}
function copyFile(src: string, dest: string) {
fs.copyFileSync(src, dest)
}
// ─── Résultat d'organisation ──────────────────────────────────
export interface OrganizeResult {
total : number
skipped : number
done : number
errors : { file: string; error: string }[]
}
// ─── Organise un torrent ──────────────────────────────────────
export async function organizeTorrent(
hash : string,
name : string, // nom du dossier torrent dans completePath
savePath: string // save_path retourné par qBittorrent
): Promise<OrganizeResult> {
const settings = readSettings()
const { mediaPath, completePath, organizeMode } = settings
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
// Trouver le torrent dans torrent_final.json
const torrent = findTorrentByName(name)
if (!torrent) {
throw new Error(`Torrent introuvable dans torrent_final.json : "${name}"`)
}
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
if (torrentFiles.length === 0) {
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
}
// Résoudre season_number par episode_id
const epSeasonMap = new Map<number, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename) {
epSeasonMap.set(ep.episode_number, ep.season_number)
}
}
// Map filename → season_number
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined) {
filenameSeasonMap.set(ep.filename, ep.season_number)
}
}
result.total = torrentFiles.length
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
// Skip si déjà organisé
if (isOrganized(hash, filename)) {
result.skipped++
continue
}
// Construire le chemin source
// save_path peut déjà contenir le nom du dossier ou pas selon le client
const torrentDir = path.join(savePath, name)
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
// Chemin source = dossier torrent + sous-dossiers + fichier
const subPath = filePath.length > 1
? path.join(...filePath.slice(0, -1), filename)
: filename
let src = path.join(torrentDir, subPath)
if (!fs.existsSync(src)) {
// Fallback: save_path sans le nom du torrent
src = path.join(torrentDirAlt, subPath)
}
if (!fs.existsSync(src)) {
// Fallback: fichier directement dans save_path/name
src = path.join(torrentDir, filename)
}
if (!fs.existsSync(src)) {
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
continue
}
// Déterminer la saison
let seasonNum = filenameSeasonMap.get(filename)
if (seasonNum === undefined) {
// Fallback : season_number = 1
seasonNum = 1
}
// Construire le chemin destination
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
// Skip si dest existe déjà
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
result.skipped++
continue
}
try {
ensureDir(destDir)
if (organizeMode === 'hardlink') {
const ok = tryHardlink(src, dest)
if (!ok) {
// Hardlink impossible (filesystems différents) → copy
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
copyFile(src, dest)
}
} else {
// move = copy + delete source
copyFile(src, dest)
fs.unlinkSync(src)
}
markOrganized(hash, filename)
result.done++
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
} catch (err) {
result.errors.push({
file : filename,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
}
}
return result
}
// ─── Auto-organise polling ────────────────────────────────────
// Appelé depuis index.ts au démarrage, toutes les 30s
// Détecte les torrents "seeding" non encore organisés
export async function autoOrganizeAll(
listFn: () => Promise<any[]>
): Promise<void> {
let torrents: any[]
try {
torrents = await listFn()
} catch { return }
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
// Vérifier si tous les fichiers sont déjà organisés
const torrent = findTorrentByName(t.name)
if (!torrent) continue
const files: any[] = torrent.torrent_files ?? []
if (files.length === 0) continue
const allDone = files.every(f => organized[t.hash]?.[f.filename])
if (allDone) continue
console.log(`[organize] Auto-organisation : ${t.name}`)
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
console.log(`[organize] ${t.name} → ${result.done} fichiers, ${result.skipped} skippés, ${result.errors.length} erreurs`)
} catch (err) {
console.error(`[organize] Erreur pour ${t.name}:`, err)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import fs from 'fs'
import path from 'path'
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
export interface Settings {
mediaPath : string // dossier Jellyfin destination ex: /media/Kai
completePath : string // dossier qB complétion ex: /downloads/complete
organizeMode : 'hardlink' | 'move'
category : string // catégorie qBittorrent
}
const defaults: Settings = {
mediaPath : '/media/Kai',
completePath : '/downloads/complete',
organizeMode : 'hardlink',
category : 'fankai',
}
export function readSettings(): Settings {
try {
if (!fs.existsSync(DATA_PATH)) return { ...defaults }
return { ...defaults, ...JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8')) }
} catch { return { ...defaults } }
}
export function writeSettings(settings: Partial<Settings>): Settings {
const current = readSettings()
const updated = { ...current, ...settings }
fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true })
fs.writeFileSync(DATA_PATH, JSON.stringify(updated, null, 2))
return updated
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Torrent Client Registry
* =======================
* Interface commune + registry de tous les clients disponibles.
* Chaque client expose son schema de configuration (FieldDef[])
* pour que le frontend génère le formulaire dynamiquement.
*/
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface FieldDef {
key : string
label : string
type : 'text' | 'password' | 'number' | 'url'
placeholder?: string
required : boolean
default? : string | number
}
/** Définition statique d'un type de client (ce qu'il expose au front) */
export interface TorrentClientDefinition {
id : string // "qbittorrent", "transmission", etc.
label : string // "qBittorrent"
fields : FieldDef[] // schema du formulaire
}
/** Instance sauvegardée d'un client configuré par l'utilisateur */
export interface SavedClient {
uuid : string // identifiant unique de cette instance
name : string // nom custom donné par l'utilisateur
type : string // id du TorrentClientDefinition
config: Record<string, string | number>
}
/** État normalisé d'un torrent (commun à tous les clients) */
export interface TorrentInfo {
hash : string
name : string
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
progress : number // 0-100
size : number // bytes
downloaded: number // bytes
speed : number // bytes/s
eta : number // secondes, -1 si inconnu
save_path : string // dossier de complétion
category : string
}
/** Interface que chaque client doit implémenter */
export interface TorrentClientDriver {
definition : TorrentClientDefinition
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
healthcheck : (config: Record<string, string | number>) => Promise<{ online: boolean; version?: string }>
add : (config: Record<string, string | number>, url: string) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
return drivers.get(type)
}
export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
function loadClients(): SavedClient[] {
try {
if (!fs.existsSync(CLIENTS_PATH)) return []
const raw = fs.readFileSync(CLIENTS_PATH, 'utf-8').trim()
if (!raw || raw === '[]') return []
return JSON.parse(raw)
} catch { return [] }
}
function saveClients(clients: SavedClient[]) {
fs.mkdirSync(path.dirname(CLIENTS_PATH), { recursive: true })
fs.writeFileSync(CLIENTS_PATH, JSON.stringify(clients, null, 2), 'utf-8')
}
export function listClients(): SavedClient[] {
return loadClients()
}
export function addClient(name: string, type: string, config: Record<string, string | number>): SavedClient {
const clients = loadClients()
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
return client
}
export function removeClient(uuid: string): boolean {
const clients = loadClients()
const filtered = clients.filter(c => c.uuid !== uuid)
if (filtered.length === clients.length) return false
saveClients(filtered)
return true
}
export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
/** Masque les champs password pour l'envoi au front */
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
if (!definition) definition = getDriver(client.type)?.definition
if (!definition) return client
const config = { ...client.config }
for (const field of definition.fields) {
if (field.type === 'password' && config[field.key]) {
config[field.key] = '••••••••'
}
}
return { ...client, config }
}
// ─── Dispatch download vers tous les clients ──────────────────────────────────
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients()
const results = []
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) {
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
continue
}
try {
await driver.add(client.config, url)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
results.push({
uuid : client.uuid,
name : client.name,
ok : false,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
}
}
return results
}
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
const clients = loadClients()
const results: (TorrentInfo & { client_uuid: string; client_name: string })[] = []
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
try {
const torrents = await driver.list(client.config, category)
for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err)
}
}
return results
}
+110
View File
@@ -0,0 +1,110 @@
/**
* qBittorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
// Mapping état qBittorrent → état normalisé
function mapState(state: string): TorrentInfo['state'] {
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
if (['pausedDL', 'pausedUP'].includes(state)) return 'paused'
if (['checkingDL', 'checkingUP', 'checkingResumeData'].includes(state)) return 'checking'
if (state === 'error' || state === 'missingFiles') return 'error'
return 'unknown'
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
form.append('password', String(config.password ?? ''))
const res = await fetch(`${config.url}/api/v2/auth/login`, {
method : 'POST',
body : form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
}
const QB: TorrentClientDriver = {
definition: {
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' },
],
},
async test(config) {
try {
await qbLogin(config)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
}
},
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` }
})
if (!res.ok) return { online: false }
return { online: true, version: (await res.text()).trim() }
} catch {
return { online: false }
}
},
async list(config, category) {
const sid = await qbLogin(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` }
})
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
const data: any[] = await res.json()
return data.map(t => ({
hash : t.hash,
name : t.name,
state : mapState(t.state),
progress : Math.round(t.progress * 100),
size : t.size,
downloaded: t.downloaded,
speed : t.dlspeed,
eta : t.eta ?? -1,
save_path : t.save_path,
category : t.category ?? '',
} satisfies TorrentInfo))
},
async add(config, url) {
const sid = await qbLogin(config)
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
},
}
export default QB