refacto: réécriture des textes, messages et commentaires

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 6a3cb21adf
commit 586eccbbb3
118 files changed
+1621 -2159

No files matched your search

+21 -36
View File
@@ -21,7 +21,7 @@ declare global {
}
}
// ── Durée JWT ─────────────────────────────────────────────────
// ── Durée de session (JWT) ────────────────────────────────────
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
@@ -66,25 +66,25 @@ export function authStatus(req: Request, res: Response): void {
// POST /api/auth/setup (premier lancement uniquement)
export function authSetup(req: Request, res: Response): void {
if (hasUsers()) {
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
logger.warn('auth', 'Tentative de création du premier compte alors qu\'un compte existe')
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
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, 'admin')
startOnboarding()
logger.info('auth', `Premier compte admin créé pour "${username}"`)
logger.info('auth', `Premier compte admin créé pour « ${username} »`)
setCookieAndRespond(res, user)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -96,16 +96,16 @@ export function authLogin(req: Request, res: Response): void {
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' }); return
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
logger.warn('auth', `Échec de connexion pour "${username}"`)
logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' }); return
}
logger.info('auth', `Connexion de "${username}" (rôle: ${user.role}, expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`)
setCookieAndRespond(res, user)
}
@@ -115,7 +115,7 @@ export function authLogout(req: Request, res: Response): void {
if (token) {
try {
const p = jwt.verify(token, JWT_SECRET) as any
logger.info('auth', `Déconnexion de "${p.username}"`)
logger.info('auth', `Déconnexion de « ${p.username} »`)
} catch {}
}
res.clearCookie('fankarr_token')
@@ -131,16 +131,16 @@ export function authMe(req: Request, res: Response): void {
export function authChangePassword(req: Request, res: Response): void {
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
res.status(400).json({ error: 'Mot de passe actuel et nouveau requis' }); return
res.status(400).json({ error: 'Mot de passe actuel et nouveau mot de passe requis' }); return
}
if (newPassword.length < 6) {
res.status(400).json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' }); return
res.status(400).json({ error: 'Le nouveau mot de passe doit contenir au moins 6 caractères' }); return
}
try {
changePassword(req.user!.id, currentPassword, newPassword)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -150,7 +150,7 @@ export function authRegenerateToken(req: Request, res: Response): void {
const token = regenerateApiToken(req.user!.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -159,31 +159,17 @@ export function authTourSeen(req: Request, res: Response): void {
try {
res.json({ tourSeenAt: markTourSeen(req.user!.id) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
// POST /api/auth/register (via lien d'invitation)
export function authRegister(req: Request, res: Response): void {
const { username, password, inviteCode } = req.body
if (!username || !password || !inviteCode) {
res.status(400).json({ error: 'Username, password et code d\'invitation requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
}
// La validation du code se fait dans le router invites (middleware)
// Ici on reçoit déjà un username/password validés — la création est déléguée au router
res.status(400).json({ error: 'Utilisez /api/invites/:code/register' })
}
// ── Middlewares ───────────────────────────────────────────────
// ── Contrôle d'accès ─────────────────────────────────────────
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' }); return
logger.debug('auth', `Accès refusé : non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Session expirée, reconnectez-vous' }); return
}
try {
const payload = jwt.verify(token, JWT_SECRET) as any
@@ -194,22 +180,21 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
req.user = user
next()
} catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' })
logger.warn('auth', `Jeton de session invalide (${req.method} ${req.path})`)
res.status(401).json({ error: 'Session expirée, reconnectez-vous' })
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
requireAuth(req, res, () => {
if (req.user?.role !== 'admin') {
logger.warn('auth', `Accès admin refusé pour "${req.user?.username}" (${req.method} ${req.path})`)
logger.warn('auth', `Accès admin refusé pour « ${req.user?.username} » (${req.method} ${req.path})`)
res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
}
next()
})
}
/** Accepte aussi l'authentification par token API (header Authorization: Bearer <token>) */
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) {
+3 -10
View File
@@ -4,9 +4,7 @@ import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
// Doit s'exécuter avant tout autre module qui lit process.env
// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
// Charge le .env du binaire Bun avant tout module qui lit process.env
;(() => {
if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath)
@@ -21,17 +19,15 @@ export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
if (eq === -1) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
// Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
// Ne pas écraser les variables déjà définies (CLI/système prioritaires)
if (key && !(key in process.env)) process.env[key] = val
}
console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) {
console.warn(`[config] Impossible de charger .env : ${err}`)
console.warn(`[config] Impossible de charger le fichier .env : ${err}`)
}
})()
@@ -40,19 +36,16 @@ export const BASE_DIR = _isBunBinary
: process.cwd()
const isInContainer = (): boolean => {
// Docker
if (fs.existsSync('/.dockerenv')) return true;
// Podman (crée un fichier /.containerenv)
// Podman
if (fs.existsSync('/.containerenv')) return true;
// Vérification via cgroup (Docker, Podman, LXC, etc.)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {}
// Vérification via /proc/1/environ (fallback général)
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true;
+1 -6
View File
@@ -1,9 +1,4 @@
/**
* gitlab-map.ts
* =============
* Map des titres API → titres GitLab pour le téléchargement des NFO.
* Partagé entre organize-worker.ts et index.ts.
*/
/** Titres de l'API Fankai vers les noms de dossiers du dépôt GitLab des NFO. */
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
+36 -43
View File
@@ -71,11 +71,11 @@ if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
// ── Migration mono-user → multi-user ──────────────────────────
// ── Migration vers le multi-utilisateur ───────────────────────
migrateIfNeeded()
migrateOnboarding()
// ── Auth ───────────────────────────────────────────────────────
// ── Authentification ───────────────────────────────────────────
app.get ('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
@@ -85,14 +85,11 @@ app.post('/api/auth/change-password', requireAuth, authChangePassword)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
// ── API publique v1 (auth par token) ──────────────────────────
// ── API publique v1 (authentification par jeton) ──────────────
app.use('/api', publicApiRouter)
// ── Garde admin : protège les préfixes réservés aux admins ────
// NOTE : app.use('/api', requireAdmin, router) s'applique à TOUTES
// les requêtes /api/* avant même que le router vérifie ses routes,
// ce qui bloquerait les utilisateurs standard. On utilise donc un
// middleware ciblé sur les chemins réellement admin-only.
// ── Garde admin par préfixe ───────────────────────────────────
// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import',
@@ -108,7 +105,7 @@ app.use('/api', (req, res, next) => {
next()
})
// ── Routers (admin + utilisateurs connectés) ───────────────────
// ── Routes (admins et utilisateurs connectés) ──────────────────
app.use('/api', usersRouter)
app.use('/api', jellyfinRouter)
app.use('/api', settingsRouter)
@@ -124,7 +121,7 @@ app.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter)
// ── Catch-all SPA ──────────────────────────────────────────────
// ── Application web (toutes les autres routes) ─────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
@@ -134,32 +131,31 @@ const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => {
const url = `http://localhost:${PORT}`
console.log(`\n FanKarr → \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
logger.info('api', `Serveur démarré — ${url}`)
console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
logger.info('api', `Serveur démarré sur ${url}`)
try {
const available = await readAvailable()
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
logger.info('api', `Catalogue chargé : ${available.length} séries`)
} catch (err) {
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
}
const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
// Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
try {
const raw = readOrganized()
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
logger.info('api', 'Migration organized.json : ancien format détecté, fichier réinitialisé')
writeOrganized({})
}
} catch (err) {
logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`)
logger.error('api', `Migration organized.json annulée : ${err instanceof Error ? err.message : err}`)
}
// Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try {
const raw = readOrganized()
let patched = 0
@@ -173,10 +169,10 @@ server.listen(PORT, async () => {
}
if (patched > 0) {
writeOrganized(raw)
logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
logger.info('api', `Migration organized.json : ${patched} entrée(s) complétée(s) (dest_dir)`)
}
} catch (err) {
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Échec de la migration dest_dir : ${err instanceof Error ? err.message : err}`)
}
loadCatalog()
@@ -185,7 +181,7 @@ server.listen(PORT, async () => {
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
.catch(err => logger.error('api', `Échec de l'analyse initiale de la médiathèque : ${err instanceof Error ? err.message : err}`))
const autoOrganize = async () => {
autoImportSchedule.lastRunAt = new Date().toISOString()
@@ -201,7 +197,7 @@ server.listen(PORT, async () => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
}
// Marquer les demandes actives comme disponibles quand des fichiers sont importés
// Demandes actives passées en « disponible » dès qu'un fichier est importé
if (result.done > 0 && result.serieId != null) {
try {
const toComplete = readRequests().filter(r =>
@@ -210,25 +206,25 @@ server.listen(PORT, async () => {
)
for (const r of toComplete) {
completeRequest(r.id)
logger.info('requests', `Demande "${r.serieName}" passée en disponible (import automatique)`)
logger.info('requests', `Demande « ${r.serieName} » passée en disponible (import automatique)`)
}
} catch (err) {
logger.warn('api', `Impossible de compléter les demandes pour série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Impossible de passer en « disponible » les demandes de la série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
}
}
const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) {
logger.info('api', `Torrent "${result.name}" supprimé après move`)
logger.info('api', `Torrent « ${result.name} » retiré du client après déplacement`)
} else {
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
logger.warn('api', `Échec du retrait de « ${result.name} » après déplacement : ${removeResult.error ?? 'aucun client n\'a pu le supprimer'}`)
}
}
}
)
} catch (err) {
logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
}
}
@@ -239,45 +235,42 @@ server.listen(PORT, async () => {
}, 10_000)
setTimeout(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${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}`))
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 30_000)
// Sync automatique des noms de fichiers toutes les heures.
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
logger.info('api', `Migration automatique des IDs : ${updated} ID(s) mis à jour`)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0)
logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`)
logger.info('api', `Dédoublonnage automatique : ${removed} entrée(s) redondante(s) supprimée(s)`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
logger.error('api', `Échec de la mise à jour horaire des imports : ${err instanceof Error ? err.message : err}`)
}
}, 60 * 60_000)
// RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
setTimeout(() => {
runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`))
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`))
runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
}, 6 * 60 * 60_000)
}, 60_000) // premier cycle 1 min après le démarrage
}, 60_000)
// Jellyfin sync — toutes les heures (no-op si Jellyfin non configuré)
// Sans effet si Jellyfin n'est pas configuré
setTimeout(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Sync initiale échouée : ${err instanceof Error ? err.message : err}`))
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Sync périodique échouée : ${err instanceof Error ? err.message : err}`))
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 2 * 60_000) // premier cycle 2 min après le démarrage
}, 2 * 60_000)
})
+5 -10
View File
@@ -1,6 +1,3 @@
/**
* Système d'invitations
*/
import fs from 'fs'
import path from 'path'
@@ -20,7 +17,7 @@ export interface Invite {
const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
// ── Helpers fichier ───────────────────────────────────────────
// ── Fichier des invitations ───────────────────────────────────
export function readInvites(): Invite[] {
try {
@@ -34,7 +31,7 @@ function writeInvites(invites: Invite[]): void {
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
}
// ── CRUD ──────────────────────────────────────────────────────
// ── Gestion des invitations ───────────────────────────────────
export function createInvite(opts: {
createdBy: string
@@ -49,12 +46,12 @@ export function createInvite(opts: {
expiresAt: opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
: null,
maxUses : opts.maxUses === undefined ? 1 : opts.maxUses, // null = illimité
maxUses : opts.maxUses === undefined ? 1 : opts.maxUses,
uses : 0,
note : opts.note,
}
writeInvites([...readInvites(), invite])
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (maxUses: ${invite.maxUses ?? '∞'}, expire: ${invite.expiresAt ?? 'jamais'})`)
logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (utilisations max : ${invite.maxUses ?? '∞'}, expiration : ${invite.expiresAt ?? 'jamais'})`)
return invite
}
@@ -68,10 +65,9 @@ export function findInvite(code: string): Invite | undefined {
return readInvites().find(i => i.code === code)
}
/** Vérifie si un code est valide (non expiré, non épuisé) */
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
const invite = findInvite(code)
if (!invite) return { valid: false, reason: 'Code d\'invitation invalide' }
if (!invite) return { valid: false, reason: 'Ce lien d\'invitation n\'est pas valide' }
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { valid: false, reason: 'Ce lien d\'invitation a expiré' }
}
@@ -81,7 +77,6 @@ export function validateInvite(code: string): { valid: true; invite: Invite } |
return { valid: true, invite }
}
/** Incrémente le compteur d'utilisations */
export function consumeInvite(code: string): void {
const invites = readInvites()
const invite = invites.find(i => i.code === code)
+2 -6
View File
@@ -22,7 +22,6 @@ export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
}
// Date de la dernière lecture d'une clé
export function cacheFetchedAt(key: string): string | null {
const entry = _cache.get(key)
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
@@ -32,10 +31,7 @@ export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
/**
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
* (~5 min).
*/
/** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) {
const cached = cacheGet(urlPath)
@@ -44,7 +40,7 @@ export async function githubGet(urlPath: string, force = false): Promise<any> {
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
if (!res.ok) throw new Error(`GitHub : HTTP ${res.status} sur ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
+7 -11
View File
@@ -1,7 +1,3 @@
/**
* Helpers pour l'API Jellyfin
* Utilisé pour la sync d'utilisateurs et la validation SSO
*/
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
@@ -39,7 +35,7 @@ export async function testJellyfinConnection(url: string, token: string): Promis
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
@@ -51,15 +47,15 @@ export async function testJellyfinConnection(url: string, token: string): Promis
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou token manquant)')
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`)
if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
return res.json()
}
// ── Validation d'un token utilisateur Jellyfin (SSO) ──────────
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo {
valid : boolean
@@ -77,13 +73,13 @@ export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToke
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken),
})
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` }
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
logger.debug('jellyfin', `Token valide pour "${user.Name}" (${user.Id.slice(0, 8)}…)`)
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
+4 -4
View File
@@ -57,7 +57,7 @@ export async function downloadNfoFolder(gitlabTitle: string, destRoot: string):
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
}
export async function checkNfoUpdates() {
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
logger.info('nfo-update', `Mise à jour des NFO disponible pour « ${gitlabTitle} » (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('nfo-update', `Échec de la mise à jour des NFO de « ${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()
+2 -2
View File
@@ -13,8 +13,8 @@ export function readOrganized(file = ORGANIZED_PATH): Organized {
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu')
catch (err) { throw new Error(`Fichier de suivi des imports (organized.json) corrompu : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Fichier de suivi des imports (organized.json) corrompu : contenu inattendu')
return data as Organized
}
+1 -14
View File
@@ -1,9 +1,6 @@
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
/**
* Torrents à envoyer pour une portée de demande.
* episodes non vide → épisodes ciblés ; sinon seasons non vide → saisons ciblées ; sinon toute la série puis voila
*/
/** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
const toDownload: RequestTorrent[] = []
const seenHashes = new Set<string>()
@@ -20,16 +17,11 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
}
/**
* Ajoute le premier torrent disponible par épisode
*/
function addEpisodesByIndex(epList: any[]) {
for (const ep of epList) {
if (ep.torrents?.length > 0) {
// Premier torrent individuel disponible
addTorrent(ep.torrents[0])
} else if (ep.paths?.length > 0) {
// Premier path dans un pack
const p = ep.paths[0]
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
@@ -48,10 +40,8 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
for (const season of serieData.seasons ?? []) {
if (!seasons.includes(season.season_number)) continue
if ((season.torrents ?? []).length > 0) {
// Premier pack saison disponible → télécharger en bloc
addTorrent(season.torrents[0])
} else {
// Pas de pack → épisode par épisode avec file_index
addEpisodesByIndex(season.episodes ?? [])
}
}
@@ -60,12 +50,10 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
if ((serieData.torrents ?? []).length > 0) {
addTorrent(serieData.torrents[0])
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
// Packs saison (premier par saison)
for (const season of serieData.seasons ?? []) {
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
}
} else {
// Fallback : épisode par épisode avec file_index
for (const season of serieData.seasons ?? []) {
addEpisodesByIndex(season.episodes ?? [])
}
@@ -75,7 +63,6 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
return toDownload
}
// Épisodes uniques concernés par la même portée
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
const keys = new Set<string>()
for (const season of serieData.seasons ?? []) {
+15 -28
View File
@@ -1,11 +1,4 @@
/**
* RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance.
*
* Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added`
* (date d'ajout dans la base metadata) est postérieur à la date d'activation
* du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont
* pas encore organisés, ils sont envoyés automatiquement au client torrent.
*/
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
import path from 'path'
import fs from 'fs'
@@ -21,7 +14,7 @@ import { readSettings } from '../settings.js'
export interface SyncedSerie {
serieId : number
serieName: string
addedAt : string // ISO — date d'activation de la surveillance
addedAt : string // date ISO d'activation de la surveillance
}
type SyncedMap = Record<number, SyncedSerie>
@@ -73,8 +66,7 @@ export function isSynced(serieId: number): boolean {
const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Série recréée sous un nouvel ID avec le même nom : la surveillance suit.
// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
// Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id))
@@ -86,7 +78,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
logger.info('rss-sync', `Surveillance de « ${entry.serieName} » déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
changed = true
} else {
orphans.push(entry)
@@ -96,7 +88,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
return orphans
}
// ── Helpers ───────────────────────────────────────────────────
// ── Épisodes déjà importés ────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> {
try {
@@ -144,7 +136,7 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
return ids
}
// ── Logique de sync ───────────────────────────────────────────
// ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
@@ -154,7 +146,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const synced = loadSynced()
const ids = Object.keys(synced).map(Number)
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
logger.info('rss-sync', `Surveillance : vérification de ${ids.length} série(s)`)
const organized = readOrganized()
@@ -176,7 +168,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) {
logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
logger.warn('rss-sync', `Série ${serieId} (« ${syncedEntry.serieName} ») absente du catalogue : retirez la surveillance dans Paramètres › Catalogue`)
continue
}
@@ -184,9 +176,8 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized)
logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`)
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
// Épisodes candidats : date_added > date d'activation ET pas encore organisés
const candidateIds = new Set<number>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -194,7 +185,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const epDate = new Date(ep.date_added)
if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) continue
// Doit avoir au moins un torrent fankai
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue
candidateIds.add(ep.id)
@@ -202,17 +192,15 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
if (candidateIds.size === 0) {
logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`)
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
continue
}
logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`)
logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`)
// Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
const coveredIds = new Set<number>()
// 1. Packs saison couvrant au moins un candidat
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (!t.fankai) continue
@@ -231,7 +219,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
}
// 2. Épisodes individuels candidats non couverts par un pack
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
@@ -265,19 +252,19 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
infohash : dl.infohash,
})
if (results.some((r: any) => r.ok)) {
logger.info('rss-sync', `Envoyé : ${dl.label}`)
logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
sent++
} else {
logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
errors++
}
} catch (err) {
logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
errors++
}
}
}
logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
return { sent, skipped, errors }
}
-1
View File
@@ -1,4 +1,3 @@
// Cadence de l'import automatique
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
export const autoImportSchedule = {
+5 -23
View File
@@ -6,7 +6,7 @@ const FANKAI_API = 'https://metadata.fankai.fr'
export 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}`)
if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`)
return res.json()
}
@@ -48,15 +48,14 @@ export async function resolveSerieData(serieId: number, action?: string): Promis
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return sd
} catch (err) {
logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(err)})`)
return null
}
}
let lastCatalogWarn = 0
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
// Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
@@ -121,14 +120,6 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
@@ -145,10 +136,6 @@ export function resolveEpNaming(ep: any, hash?: string | null): {
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
export function computeExpectedName(
ep : any,
orgEntry: any,
@@ -164,11 +151,7 @@ export function computeExpectedName(
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
/** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
@@ -226,8 +209,7 @@ export function computeSerieDownloadState(
}
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
+6 -19
View File
@@ -1,10 +1,3 @@
/**
* logger.ts
* =========
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
* Niveaux : debug | info | warn | error
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
*/
import fs from 'fs'
import path from 'path'
@@ -13,15 +6,15 @@ import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface LogEntry {
at : string // ISO timestamp
at : string // date ISO
level : LogLevel
source : string // 'organize' | 'api' | 'scraper' | ...
source : string // 'api', 'organize', 'torrent-clients', 'rss-sync'…
msg : string
meta? : Record<string, any>
}
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const MAX_LINES = 2000 // rotation automatique au-delà
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
const IS_PROD = process.env.NODE_ENV === 'production'
@@ -51,14 +44,12 @@ function writeLine(entry: LogEntry) {
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
// Console
const prefix = `[${source}]`
if (level === 'error') console.error(prefix, msg, meta ?? '')
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
else if (level === 'info') console.log(prefix, msg, meta ?? '')
// Disque — pas les debug en prod
if (level === 'debug' && IS_PROD) return
writeLine(entry)
}
@@ -73,9 +64,9 @@ export const logger = {
// ── Lecture pour l'API ────────────────────────────────────────
export interface LogsReadOptions {
limit? : number // nb de lignes (défaut 100)
level? : LogLevel | 'all' // filtre niveau
source? : string // filtre source
limit? : number // nombre de lignes (100 par défaut)
level? : LogLevel | 'all'
source? : string
}
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
@@ -84,22 +75,18 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
let lines = content.split('\n').filter(Boolean)
// Parse
let entries: LogEntry[] = []
for (const line of lines) {
try { entries.push(JSON.parse(line)) } catch {}
}
// Filtres
if (opts.level && opts.level !== 'all')
entries = entries.filter(e => e.level === opts.level)
if (opts.source)
entries = entries.filter(e => e.source === opts.source)
// Plus récents en premier
entries.reverse()
// Limite
const limit = opts.limit ?? 100
return entries.slice(0, limit)
} catch { return [] }
+2 -2
View File
@@ -20,7 +20,7 @@ export function readOnboarding(): OnboardingState {
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt }
}
// Installation antérieure à l'assistant : déjà configurée → rien n'est imposé
// Installation configurée avant l'assistant : on ne l'impose pas
export function migrateOnboarding(): boolean {
const settings = readSettings()
if (settings.onboardingCompletedAt || settings.onboardingStep) return false
@@ -29,7 +29,7 @@ export function migrateOnboarding(): boolean {
const now = new Date().toISOString()
writeSettings({ onboardingCompletedAt: now })
markAllToursSeen(now)
logger.info('onboarding', 'Installation existante détectée — assistant marqué comme terminé')
logger.info('onboarding', 'Installation existante détectée : assistant marqué comme terminé')
return true
}
+39 -69
View File
@@ -1,8 +1,3 @@
/**
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
*/
import fs from 'fs'
import fsp from 'fs/promises'
@@ -12,7 +7,7 @@ import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
// ─── Utils log ────────────────────────────────────────────────
// ─── Journal ──────────────────────────────────────────────────
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 }) }
@@ -33,7 +28,7 @@ export interface OrgEntry {
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ─────────────────────────────────────────────────
// ─── Réglages ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
@@ -42,7 +37,7 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
}
// ─── Organized log ────────────────────────────────────────────
// ─── Suivi des imports ────────────────────────────────────────
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
}
@@ -51,11 +46,8 @@ function isOrganized(hash: string, episodeId: number): boolean {
return !!readOrganized()[hash]?.[String(episodeId)]
}
// ─── Lookup torrent dans les seriesData ───────────────────────
// ─── Recherche du torrent dans le catalogue ───────────────────
/**
* Recherche par infohash (méthode principale).
*/
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
@@ -94,11 +86,7 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null
}
/**
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
*/
/** Hash non résolu (ex. dbid_X Synology) : recherche par torrent_name ou title, renvoie le vrai infohash. */
function findTorrentByName(
name : string,
seriesData: any[],
@@ -117,7 +105,6 @@ function findTorrentByName(
}
for (const season of sd.seasons ?? []) {
// Pack saison
for (const t of season.torrents ?? []) {
if (!t.infohash) continue
if (
@@ -126,7 +113,6 @@ function findTorrentByName(
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
// Torrent épisode unique
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (!t.infohash) continue
@@ -170,7 +156,6 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
@@ -179,7 +164,7 @@ function buildFileMap(
: null
if (!nfoSupport && !fmtName) {
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
warn(`formatted_name absent pour l'épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) : nom NFO ou nom d'origine utilisé`)
}
const srcExt = path.extname(filename)
@@ -218,7 +203,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── GitLab NFO downloader ────────────────────────────────────
// ─── Téléchargement des NFO ───────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -239,7 +224,7 @@ async function fetchBinary(url: string): Promise<Buffer> {
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
log(`Téléchargement des NFO et images depuis GitLab pour « ${gitlabTitle} »`)
let files: any[] = []
try {
@@ -254,14 +239,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++
}
} catch (err) {
warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
warn(`Dossier GitLab introuvable pour « ${gitlabTitle} » : ${err instanceof Error ? err.message : err}`)
return
}
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour « ${gitlabTitle} »`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
log(`${fileEntries.length} fichier(s) NFO et images à télécharger pour « ${gitlabTitle} »`)
let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) {
@@ -275,14 +260,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
fs.writeFileSync(destPath, data)
downloaded++
} catch (err) {
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
warn(`Échec du téléchargement de « ${relativePath} » : ${err instanceof Error ? err.message : err}`)
failed++
}
}
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
log(`NFO de « ${gitlabTitle} » : ${downloaded} téléchargé(s), ${skipped} déjà présent(s)${failed > 0 ? `, ${failed} échec(s)` : ''}`)
}
// ─── Filesystem ops ───────────────────────────────────────────
// ─── Fichiers ─────────────────────────────────────────────────
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
@@ -295,10 +280,7 @@ function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
}
/**
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
*/
/** Supprime un fichier en réessayant si EBUSY (fichier ouvert sous Windows). */
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
@@ -306,7 +288,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
warn(`Fichier « ${path.basename(filePath)} » occupé, nouvelle tentative (${attempt + 1}/${maxAttempts - 1})`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
@@ -318,7 +300,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
debug(`Hardlink impossible (${err instanceof Error ? err.message : err}), copie du fichier`)
return false
}
}
@@ -331,7 +313,7 @@ function sanitizeDirName(name: string): string {
.trim()
}
// ─── Organise un torrent ──────────────────────────────────────
// ─── Import d'un torrent ──────────────────────────────────────
async function organizeTorrent(
hash : string,
name : string,
@@ -344,7 +326,7 @@ async function organizeTorrent(
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`Hash ${hash} introuvable dans les données série`)
error(`Hash ${hash} introuvable dans le catalogue`)
throw new Error(`Torrent introuvable : ${hash}`)
}
@@ -352,13 +334,13 @@ async function organizeTorrent(
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(rawTitle)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
debug(`Série identifiée : « ${serieTitle} » (mode : ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
// ── Cas torrent épisode unique ────────────────────────────
// ── Torrent d'un seul épisode ─────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
@@ -374,7 +356,6 @@ async function organizeTorrent(
}
}
const filename = filePath?.split('/').pop() ?? name
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
@@ -393,9 +374,8 @@ async function organizeTorrent(
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
warn(`Skip (incomplet / priorité 0) : ${filename}`)
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
@@ -414,7 +394,7 @@ async function organizeTorrent(
if (!src) {
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
return { ...result, total: 1, errors: [{ file: filename, error: `Fichier source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
}
if (!fs.existsSync(dest)) {
@@ -431,7 +411,7 @@ async function organizeTorrent(
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
}
@@ -441,16 +421,16 @@ async function organizeTorrent(
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
log(`${destName} → Saison ${season.season_number}`)
log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
// ── Pack saison / intégrale ───────────────────────────────
// ── Pack saison ou intégrale ──────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) {
warn(`Aucun fichier mappé pour "${name}"`)
warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
return result
}
@@ -459,10 +439,8 @@ async function organizeTorrent(
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Vérification de complétion : si on a les infos de progression par fichier,
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
warn(`Skip (incomplet / priorité 0) : ${filename}`)
warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
result.skipped++
continue
}
@@ -477,8 +455,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
// Pas encore téléchargé ou hors sélection (priorité 0)
warn(`Ignoré (fichier absent du disque) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++
continue
}
@@ -522,10 +500,10 @@ async function organizeTorrent(
await unlinkWithRetry(src)
}
result.done++
debug(`${nfo_filename} → Saison ${season_number}`)
debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`Échec import "${filename}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
error(`Échec de l'import de « ${filename} » : ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
@@ -552,24 +530,21 @@ parentPort?.on('message', async (msg: any) => {
for (const t of torrents) {
if (t.state !== 'seeding') continue
// Résolution principale : par infohash
let found = findTorrentByHash(t.hash, seriesData)
let effectiveHash = t.hash
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
// qui correspond au champ torrent_name dans les données série.
// Hash non résolu (ex. dbid_X Synology) : recherche par nom de dossier
if (!found && t.name) {
const byName = findTorrentByName(t.name, seriesData)
if (byName) {
found = byName
effectiveHash = byName.infohash
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
debug(`Hash résolu par nom de torrent : « ${t.name} » (${effectiveHash.slice(0, 8)}…)`)
}
}
if (!found) {
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
debug(`Torrent « ${t.name} » (${t.hash.slice(0, 8)}…) absent du catalogue (pas encore à jour ?)`)
continue
}
@@ -582,25 +557,20 @@ parentPort?.on('message', async (msg: any) => {
: Object.keys(orgHash).length > 0
if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
debug(`« ${t.name} » déjà entièrement importé`)
continue
}
try {
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
// Fichiers terminés (nom seul) ; null si le client ne donne pas les noms
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
// on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
@@ -612,7 +582,7 @@ parentPort?.on('message', async (msg: any) => {
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
error(`Échec de l'import de « ${t.name} » :${err instanceof Error ? err.message : err}`)
}
}
+53 -79
View File
@@ -1,8 +1,3 @@
/**
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
*/
import fs from 'fs'
import path from 'path'
@@ -63,7 +58,7 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) {
logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Échec de l'enregistrement de l'import (épisode ${msg.episodeId}) :${err instanceof Error ? err.message : err}`)
}
} else if (msg.type === 'result') {
onResult(msg)
@@ -72,13 +67,13 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
}
})
worker.on('error', err => finish(err))
worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined))
worker.on('exit', code => finish(code > 1 ? new Error(`Arrêt inattendu du worker (code ${code})`) : undefined))
worker.postMessage({ type: 'run', torrents, seriesData })
})
}
// ── Scan initial ──────────────────────────────────────────────
// ── Analyse de la médiathèque ─────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
@@ -96,11 +91,11 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
logger.warn('organize', `Dossier de la médiathèque introuvable : ${mediaPath}`)
return result
}
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
logger.info('organize', `Analyse de la médiathèque : ${mediaPath}`)
const filenameIndex = new Map<string, {
hashes : string[]
@@ -115,7 +110,6 @@ export async function scanMediaPath(
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
// Helper : indexer par nom sans extension
const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
@@ -134,14 +128,12 @@ export async function scanMediaPath(
})
}
// 1. Noms depuis les paths (avec vrai infohash)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash || !p.path) continue
const hash = p.infohash.toLowerCase()
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
const pathNfo = p.nfo_filename ?? ep.nfo_filename
if (pathNfo) idx(pathNfo, hash, pathNfo)
const pathFmt = p.formatted_name ?? ep.formatted_name
@@ -151,7 +143,6 @@ export async function scanMediaPath(
}
}
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
@@ -166,11 +157,10 @@ export async function scanMediaPath(
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Analyse annulée : ${err instanceof Error ? err.message : err}`)
return result
}
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
let noMatch = 0
@@ -205,8 +195,7 @@ export async function scanMediaPath(
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt)
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
// À défaut, un nom de l'index qui préfixe le fichier (ex. "X.S01E01.MULTI.1080p" pour "X.S01E01.MULTI.1080p.x264-FANKAI")
if (!match) {
const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) {
@@ -221,8 +210,8 @@ export async function scanMediaPath(
noMatch++
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
const closeStr = close.length > 0 ? ` (candidats : ${close.map(c => `« ${c} »`).join(', ')})` : ' (aucun candidat proche)'
logger.warn('organize', `Analyse : aucune correspondance pour « ${nameWithoutExt} »${closeStr}`)
continue
}
@@ -239,11 +228,11 @@ export async function scanMediaPath(
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: entry.name, // vrai nom sur disque
dest_filename: entry.name,
dest_path : full,
}
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
logger.debug('organize', `Analyse : « ${entry.name} » associé à l'épisode ${episodeId} (S${season}E${episode})`)
}
}
}
@@ -251,7 +240,7 @@ export async function scanMediaPath(
walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
if (mediaEmpty) logger.warn('organize', `Analyse : aucun fichier trouvé dans ${mediaPath}, entrées existantes conservées`)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set(
@@ -270,7 +259,6 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings()
let autoRemoved = 0
if (autoUnimportMissing && !mediaEmpty) {
@@ -280,7 +268,7 @@ export async function scanMediaPath(
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
logger.info('organize', `Retrait automatique : fichier introuvable « ${destPath} »`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
@@ -292,12 +280,12 @@ export async function scanMediaPath(
}
lastScan = { at: new Date().toISOString(), ...result }
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
logger.info('organize', `Analyse terminée : ${result.found} fichier(s), ${result.added} ajouté(s), ${removed} entrée(s) obsolète(s) retirée(s) du suivi${autoRemoved > 0 ? `, ${autoRemoved} retirée(s) (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} sans correspondance` : ''}`)
return result
}
// ── Auto-import ───────────────────────────────────────────────
// ── Import automatique ────────────────────────────────────────
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
@@ -306,16 +294,16 @@ export async function autoOrganizeAll(
): Promise<void> {
const { autoImport, mediaPath } = readSettings()
if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip')
logger.debug('organize', 'Import automatique désactivé')
return
}
if (!mediaPath?.trim()) {
logger.debug('organize', 'Médiathèque non configurée — skip')
logger.debug('organize', 'Médiathèque non configurée')
return
}
if (_queued > 0) {
logger.debug('organize', 'Import déjà en cours — skip')
logger.debug('organize', 'Import déjà en cours')
return
}
@@ -354,25 +342,25 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) {
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) : lancement de l'import automatique`)
for (const t of newlySeeding) {
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
logger.debug('organize', `Torrent terminé : « ${t.name} »`)
}
} else {
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
logger.debug('organize', `Import automatique : vérification de ${seedingCount} torrent(s) terminé(s)`)
}
try {
await enqueue(() => runWorker(torrents, seriesData, msg => {
if (msg.errors.length > 0) {
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
logger.warn('organize', `« ${msg.name} » : ${msg.done} importé(s), ${msg.skipped} ignoré(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else if (msg.done > 0) {
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
logger.info('organize', `« ${msg.name} » : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
} else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
logger.debug('organize', `« ${msg.name} » : rien à importer (${msg.skipped} ignoré(s))`)
}
onResult?.({
hash : msg.hash,
@@ -384,25 +372,21 @@ export async function autoOrganizeAll(
errorFiles: msg.errors,
})
}))
logger.debug('organize', 'Worker terminé')
logger.debug('organize', 'Worker d\'import terminé')
} catch (err) {
logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Erreur du worker : ${err instanceof Error ? err.message : err}`)
}
}
// ── Migration des IDs épisodes ────────────────────────────────
//
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
// ── Migration des IDs d'épisodes ──────────────────────────────
// Les IDs du scraper changent : on les retrouve via (infohash, saison, épisode)
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel
// hashEpMap : infohash → Map<"S:E", new_episode_id>
// infohash : Map<"saison:épisode", ID actuel>
const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>()
@@ -421,11 +405,10 @@ export async function migrateOrganizedEpisodeIds(
}
}
// 2. Charger organized.json
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Migration des IDs annulée :${err instanceof Error ? err.message : err}`)
return { updated: 0, orphaned: 0 }
}
@@ -442,51 +425,45 @@ export async function migrateOrganizedEpisodeIds(
const oldId = Number(oldIdStr)
if (validIds.has(oldId)) {
// ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry
continue
}
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key)
if (newId && newId !== oldId) {
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
logger.info('organize', `Migration des IDs : entrée de l'épisode ${oldId} supprimée, ${newId} déjà présent (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
logger.info('organize', `Migration des IDs : épisode ${oldId} remplacé par ${newId} (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
}
} else {
// Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++
newEpisodes[oldIdStr] = entry
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
logger.warn('organize', `Migration des IDs : épisode ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
// Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (changed) {
writeOrganized(organized, organizedPath)
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
logger.info('organize', `Migration des IDs terminée : ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolu(s)` : ''}`)
} else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
logger.debug('organize', `Migration des IDs : aucun changement${orphaned > 0 ? ` (${orphaned} ID(s) inconnu(s) conservé(s))` : ''}`)
}
return { updated, orphaned }
}
// ── Déduplication ─────────────────────────────────────────────
// ── Dédoublonnage ─────────────────────────────────────────────
export function dedupeOrganizedEpisodes(
organizedPath: string,
): { removed: number } {
@@ -497,7 +474,7 @@ export function dedupeOrganizedEpisodes(
return { removed: 0 }
}
// episode_id → [{ hash, entry }]
// episode_id : [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
@@ -517,19 +494,19 @@ export function dedupeOrganizedEpisodes(
if (present.includes(c)) continue
delete (organized[c.hash] as any)[episodeId]
removed++
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
logger.info('organize', `Dédoublonnage : épisode ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
}
}
if (removed > 0) {
writeOrganized(organized, organizedPath)
logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
logger.info('organize', `Dédoublonnage terminé : ${removed} entrée(s) redondante(s) supprimée(s)`)
}
return { removed }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
// ── Renommage selon le catalogue ──────────────────────────────
export async function syncFilenameChanges(
seriesData : any[],
@@ -539,7 +516,7 @@ export async function syncFilenameChanges(
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Renommage selon le catalogue annulé :${err instanceof Error ? err.message : err}`)
return { renamed: 0, errors: 0 }
}
@@ -565,7 +542,6 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
@@ -583,7 +559,7 @@ export async function syncFilenameChanges(
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
continue
}
fs.renameSync(oldPath, newPath)
@@ -596,10 +572,10 @@ export async function syncFilenameChanges(
retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
errors++
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Échec du renommage de l'épisode ${ep.id} :${err instanceof Error ? err.message : err}`)
}
}
}
@@ -609,7 +585,7 @@ export async function syncFilenameChanges(
writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
logger.info('organize', `Noms de fichiers mis à jour : ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
@@ -626,14 +602,12 @@ export async function organizeTorrent(
const key = hash.toLowerCase()
const pending = _pendingManual.get(key)
if (pending) {
logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`)
logger.info('organize', `Import manuel de « ${name} » déjà en file d'attente`)
return pending
}
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : 'lancé'} pour "${name}" (${hash})`)
logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file d\'attente' : 'lancé'} pour « ${name} » (${hash})`)
// files : progression par fichier récupérée depuis le client torrent.
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
// Progression par fichier : le worker ignore les fichiers incomplets (évite EBUSY sous Windows)
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
@@ -647,16 +621,16 @@ export async function organizeTorrent(
result.done = msg.done
result.errors = msg.errors
if (msg.errors.length > 0) {
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
logger.warn('organize', `Import de « ${name} » : ${msg.done} importé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else {
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
logger.info('organize', `Import de « ${name} » terminé : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
}
})
} catch (err) {
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
logger.error('organize', `Échec de l'import de « ${name} » :${err instanceof Error ? err.message : err}`)
throw err
}
return result
+14 -27
View File
@@ -1,6 +1,3 @@
/**
* Système de demandes utilisateurs
*/
import fs from 'fs'
import path from 'path'
@@ -14,8 +11,8 @@ export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
export interface Requester {
userId : string
username : string
seasons : number[] // numéros de saison ; [] = toutes les saisons dispo
episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé
seasons : number[] // numéros de saison ; [] = toutes les saisons disponibles
episodes : number[] // IDs d'épisodes ciblés ; [] = aucun
requestedAt: string
}
@@ -32,7 +29,7 @@ export interface SerieRequest {
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
// ── Helpers fichier ───────────────────────────────────────────
// ── Fichier des demandes ──────────────────────────────────────
export function readRequests(): SerieRequest[] {
try {
@@ -46,7 +43,7 @@ function writeRequests(requests: SerieRequest[]): void {
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2))
}
/** Union des saisons demandées sur tous les requesters ([] = toutes) */
/** Saisons demandées par l'ensemble des demandeurs ([] = toutes). */
export function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
const all = new Set<number>()
@@ -54,33 +51,26 @@ export function mergedSeasons(req: SerieRequest): number[] {
return [...all].sort((a, b) => a - b)
}
/** Union des IDs d'épisodes spécifiques sur tous les requesters */
export function mergedEpisodes(req: SerieRequest): number[] {
const all = new Set<number>()
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
return [...all].sort((a, b) => a - b)
}
// ── CRUD ──────────────────────────────────────────────────────
// ── Création et suivi ─────────────────────────────────────────
/**
* Créer ou fusionner une demande.
* - Si une demande active (non rejected/completed) existe pour la série → fusionne les saisons de cet user
* - Sinon → crée une nouvelle demande
*/
export function upsertRequest(
userId : string,
serieId : number,
serieName: string,
seasons : number[], // [] = toutes les saisons
episodes : number[] = [], // IDs d'épisodes spécifiques
episodes : number[] = [],
): SerieRequest {
const user = findById(userId)
if (!user) throw new Error('Utilisateur introuvable')
const requests = readRequests()
// Chercher une demande active (pas rejected ni completed) pour cette série
const existing = requests.find(r =>
r.serieId === serieId &&
r.status !== 'rejected' &&
@@ -90,15 +80,13 @@ export function upsertRequest(
const now = new Date().toISOString()
if (existing) {
// Mettre à jour ou ajouter ce requester dans la demande existante
const idx = existing.requesters.findIndex(r => r.userId === userId)
if (idx >= 0) {
const r = existing.requesters[idx]
// "Tout" = seasons=[] ET episodes=[] (aucune granularité)
const newIsAll = seasons.length === 0 && episodes.length === 0
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
if (newIsAll || curIsAll) {
// Dès que l'une des deux est "toutes les saisons" → effacer la granularité
// Si l'une couvre toute la série : plus de filtre saison ni épisode
r.seasons = []
r.episodes = []
} else {
@@ -111,11 +99,10 @@ export function upsertRequest(
}
existing.updatedAt = now
writeRequests(requests)
logger.info('requests', `Demande mise à jour pour "${serieName}" par "${user.username}"`)
logger.info('requests', `Demande de « ${serieName} » complétée par « ${user.username} »`)
return existing
}
// Nouvelle demande
const request: SerieRequest = {
id : crypto.randomUUID(),
serieId,
@@ -126,7 +113,7 @@ export function upsertRequest(
updatedAt : now,
}
writeRequests([...requests, request])
logger.info('requests', `Nouvelle demande pour "${serieName}" par "${user.username}"`)
logger.info('requests', `Nouvelle demande de « ${serieName} » par « ${user.username} »`)
return request
}
@@ -138,7 +125,7 @@ export function approveRequest(id: string): SerieRequest {
req.rejectionMessage = undefined
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" approuvée`)
logger.info('requests', `Demande « ${req.serieName} » approuvée`)
return req
}
@@ -150,7 +137,7 @@ export function rejectRequest(id: string, message?: string): SerieRequest {
req.rejectionMessage = message
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" refusée`)
logger.info('requests', `Demande « ${req.serieName} » refusée`)
return req
}
@@ -170,7 +157,7 @@ export function deleteRequest(id: string): void {
writeRequests(requests.filter(r => r.id !== id))
}
/** Retire un utilisateur surune demande en attente et la demande disparaît s'il était seul demandeur */
/** Retire l'utilisateur d'une demande en attente ; supprime la demande s'il était seul. */
export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
const requests = readRequests()
const req = requests.find(r => r.id === id)
@@ -181,12 +168,12 @@ export function withdrawRequest(id: string, userId: string): { deleted: boolean;
req.requesters = req.requesters.filter(r => r.userId !== userId)
if (req.requesters.length === 0) {
writeRequests(requests.filter(r => r.id !== id))
logger.info('requests', `Demande "${req.serieName}" annulée par "${username}"`)
logger.info('requests', `Demande « ${req.serieName} » annulée par « ${username} »`)
return { deleted: true, request: null }
}
req.updatedAt = new Date().toISOString()
writeRequests(requests)
logger.info('requests', `"${username}" retiré de la demande "${req.serieName}"`)
logger.info('requests', `« ${username} » retiré de la demande « ${req.serieName} »`)
return { deleted: false, request: req }
}
+6 -6
View File
@@ -17,7 +17,7 @@ router.post('/download', requireAuth, async (req, res) => {
const torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
if (!url) { res.status(400).json({ error: 'Lien du torrent ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
@@ -29,13 +29,13 @@ router.post('/download', requireAuth, async (req, res) => {
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true'
if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
if (!hash) { res.status(400).json({ error: 'Hash du torrent requis' }); return }
try {
const result = await dispatchRemove(hash, deleteFiles)
res.json(result)
} catch (err) {
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la suppression du torrent : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -76,8 +76,8 @@ router.get('/downloads', requireAuth, async (_req, res) => {
}))
res.json(enriched)
} catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement des téléchargements : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+84 -90
View File
@@ -16,7 +16,9 @@ import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type O
const router = Router()
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)`
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le catalogue Fankai. Synchronisez le catalogue ou consultez les journaux.`
const ORGANIZED_UNREADABLE = 'Fichier de suivi des imports (organized.json) illisible'
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
@@ -83,13 +85,13 @@ function removeEmptyDirs(dir: string): void {
router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'serie_id et items requis' }); return
res.status(400).json({ error: 'Série ou fichiers à importer manquants' }); return
}
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
// Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
// Copies asynchrones : modifications réappliquées sur le fichier relu à la fin
const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
@@ -125,12 +127,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
// Nettoyer toute entrée organized qui référence ce même fichier physique
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
// Réassignation : retirer les entrées qui pointent ce fichier sous un autre épisode
for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (fichier réassigné)`)
}
}
apply(data => {
@@ -143,16 +144,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (file_path !== destPath) {
const serieRootPath = path.join(mediaPath, serieTitle)
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
// Si une ancienne version de cet épisode existe à la destination (réassignation),
// la supprimer avant le rename pour éviter un état incohérent.
if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
logger.info('api', `Import manuel (réassignation) : suppression de l'ancien fichier « ${destFilename} »`)
for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath)
logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (ancien fichier supprimé)`)
}
}
apply(data => {
@@ -164,7 +162,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath)
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
logger.info('api', `Import manuel : « ${srcFilename} » renommé en « ${destFilename} »`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
@@ -176,10 +174,10 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else {
await fs.promises.copyFile(file_path, destPath)
}
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
}
} else {
logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
logger.debug('api', `Import manuel : « ${srcFilename} » déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
const entry = {
@@ -190,16 +188,16 @@ router.post('/manual-import', requireAuth, async (req, res) => {
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
logger.error('api', `Échec de l'import manuel de « ${srcFilename} » : ${msg}`)
}
}
if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) {
logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
logger.error('api', `Import manuel : échec de l'enregistrement (${err instanceof Error ? err.message : err})`)
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
}
@@ -222,9 +220,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
}
logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
logger.info('api', `NFO téléchargés pour « ${gitlabTitle} » (import manuel)`)
} catch (err) {
logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Échec du téléchargement des NFO de « ${gitlabTitle} » :${err instanceof Error ? err.message : err}`)
}
})()
}
@@ -232,7 +230,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
// ── Récap organisés d'une série ────────────────────────────────
// ── Épisodes importés d'une série ──────────────────────────────
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
@@ -266,20 +264,20 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
}
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Rename épisode ─────────────────────────────────────────────
// ── Renommage d'un épisode ─────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
if (!serie_id || !episode_id) { res.status(400).json({ error: 'Série ou épisode manquant' }); return }
const { nfoSupport } = readSettings()
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -302,30 +300,30 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
if (!fs.existsSync(oldPath)) throw new Error('Fichier introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier nommé « ${newName} » existe déjà`)
fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
writeOrganized(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
logger.info('api', `Renommage : « ${orgEntry.dest_filename} » en « ${newName} »`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du renommage de l'épisode ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Désimport série complète ───────────────────────────────────
// ── Retrait d'une série ────────────────────────────────────────
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
const sd = await resolveSerieData(Number(serieId), 'Désimport série')
const sd = await resolveSerieData(Number(serieId), 'Retrait de la série')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -356,38 +354,36 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
// Supprimer le dossier de la série s'il existe encore
if (serieFolder && fs.existsSync(serieFolder)) {
try {
fs.rmSync(serieFolder, { recursive: true, force: true })
logger.info('api', `Dossier série supprimé : ${serieFolder}`)
logger.info('api', `Dossier de la série supprimé : ${serieFolder}`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le dossier série "${serieFolder}" : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Impossible de supprimer le dossier de la série « ${serieFolder} » :${err instanceof Error ? err.message : err}`)
}
}
}
// Supprimer les demandes "disponible" liées à cette série
const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
for (const r of completedRequests) {
try { deleteRequest(r.id) } catch {}
}
if (completedRequests.length > 0)
logger.info('api', `Désimport série ${serieId} — ${completedRequests.length} demande(s) "disponible" supprimée(s)`)
logger.info('api', `Retrait de la série ${serieId} : ${completedRequests.length} demande(s) « disponible » supprimée(s)`)
logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
logger.info('api', `Retrait de la série ${serieId} : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport saison ───────────────────────────────────────────
// ── Retrait d'une saison ───────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
const sd = await resolveSerieData(Number(serieId), 'Désimport saison')
const sd = await resolveSerieData(Number(serieId), 'Retrait de la saison')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
@@ -413,17 +409,17 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
logger.info('api', `Retrait de la saison ${seasonId} (série ${serieId}) : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
// ── Désimport épisode ──────────────────────────────────────────
// ── Retrait d'un épisode ───────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
@@ -437,7 +433,7 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p)
deletedPaths.add(p)
logger.info('api', `Désimport + suppression fichier : "${p}"`)
logger.info('api', `Retrait et suppression du fichier « ${p} »`)
}
}
const emptyHashes: string[] = []
@@ -446,7 +442,6 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
writeOrganized(organized)
// Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
@@ -454,16 +449,16 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
}
}
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
logger.info('api', `Retrait de l'épisode ${episodeId}${removedTorrents.length ? `, ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Désimport échoué : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du retrait de l'épisode ${episodeId} : ${msg}`)
res.status(500).json({ error: msg })
}
})
// ── Récap global des imports ───────────────────────────────────
// ── Récapitulatif des imports ──────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
@@ -478,8 +473,7 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
// Chercher d'abord via ep.paths : le hash détermine le formatted_name
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
@@ -508,16 +502,16 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
: []
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
} catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du récapitulatif des imports : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Retrait des entrées orphelines ─────────────────────────────
// ── Retrait des épisodes disparus du catalogue ─────────────────
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
const { series, complete } = await loadCatalogStatus()
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return }
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard' }); return }
let removed = 0
try {
updateOrganized(data => {
@@ -529,19 +523,19 @@ router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
return removed > 0
})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`)
logger.info('api', `Épisodes disparus du catalogue retirés du suivi : ${removed}`)
res.json({ ok: true, removed })
})
// ── Dossier série ──────────────────────────────────────────────
// ── Dossier de série ───────────────────────────────────────────
router.get('/organized-folders', requireAdmin, async (_req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath) { res.json([]); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
try {
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
const catalog = await loadCatalog()
@@ -553,8 +547,8 @@ router.get('/organized-folders', requireAdmin, async (_req, res) => {
}
res.json(result)
} catch (err) {
logger.error('api', `organized-folders échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la vérification des dossiers de séries : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
@@ -565,7 +559,7 @@ router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const { mediaPath } = readSettings()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath)
const current = [...serieFolders(sd, organized, mediaPath, others)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
@@ -574,13 +568,13 @@ router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId, 'Renommage dossier')
const sd = await resolveSerieData(serieId, 'Renommage du dossier')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return }
if (!mediaPath) { res.status(400).json({ error: 'Chemin de la médiathèque non configuré' }); return }
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
let catalog: any[]
try { catalog = await loadCatalog() }
catch (err) { res.status(503).json({ error: `Catalogue indisponible : ${err instanceof Error ? err.message : err}` }); return }
@@ -595,7 +589,7 @@ router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const targets = [...moves.values()]
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
if (conflicts.length > 0) {
res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
res.status(409).json({ error: `${conflicts.length} fichier(s) existe(nt) déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
}
const done = new Map<string, string>()
@@ -616,19 +610,19 @@ router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
let updated = 0
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
catch (err) {
logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return
logger.error('api', `Renommage du dossier de la série ${serieId} : fichiers déplacés mais suivi non mis à jour (${err instanceof Error ? err.message : err})`)
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez une analyse de la médiathèque' }); return
}
const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"`
const label = `« ${sources.map(s => path.basename(s)).join(' », « ')} » en « ${path.basename(expected)} »`
if (failure) {
logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
logger.error('api', `Renommage du dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
res.status(500).json({ error: `Déplacement interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
}
logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
logger.info('api', `Dossier de série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
res.json({ ok: true, moved: done.size, updated })
})
// ── Rename en masse ────────────────────────────────────────────
// ── Renommage groupé ───────────────────────────────────────────
router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id, serie_ids } = req.body
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
@@ -636,7 +630,7 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
const seriesData = await loadCatalog()
let organized: Organized
try { organized = readOrganized() }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
@@ -655,23 +649,23 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
const msg = `Fichier introuvable : « ${oldPath} »`
logger.error('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
const msg = `Un fichier nommé « ${expectedName} » existe déjà`
logger.warn('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Échec du renommage de l'épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
@@ -681,11 +675,11 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
// ── Purge NFO / images des dossiers série ─────────────────────
// ── Purge des NFO et images ───────────────────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
res.status(400).json({ error: 'Chemin de la médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
@@ -703,10 +697,10 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
try {
fs.unlinkSync(full)
deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`)
logger.debug('api', `Purge NFO : « ${full} » supprimé`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('api', `Purge NFO : impossible de supprimer « ${full} » : ${msg}`)
errors.push(full)
}
}
@@ -714,7 +708,7 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
}
walk(mediaPath)
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
logger.info('api', `Purge NFO : ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
+7 -7
View File
@@ -25,7 +25,7 @@ router.post('/invites', requireAdmin, (req, res) => {
})
res.json(invite)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -35,11 +35,11 @@ router.delete('/invites/:code', requireAdmin, (req, res) => {
deleteInvite(String(req.params.code))
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// GET /api/invites/:code/info (public — pour afficher la page d'inscription)
// GET /api/invites/:code/info (public : page d'inscription)
router.get('/invites/:code/info', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
@@ -48,7 +48,7 @@ router.get('/invites/:code/info', (req, res) => {
res.json({ valid: true, note: result.invite.note ?? null })
})
// POST /api/invites/:code/register (public — inscription via invitation)
// POST /api/invites/:code/register (public : inscription par invitation)
router.post('/invites/:code/register', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
@@ -57,10 +57,10 @@ router.post('/invites/:code/register', (req, res) => {
const { username, password } = req.body
if (!username || !password) {
res.status(400).json({ error: 'username et password requis' }); return
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
@@ -68,7 +68,7 @@ router.post('/invites/:code/register', (req, res) => {
consumeInvite(req.params.code)
res.json({ success: true, user: safeUser(user) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+5 -9
View File
@@ -6,17 +6,15 @@ import { logger } from '../logger.js'
const router = Router()
// GET /api/jellyfin/settings
router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({
jellyfinUrl,
// Ne jamais retourner le token en clair — juste indiquer s'il est configuré
// Clé API jamais renvoyée : seulement sa présence
hasToken: !!jellyfinAdminToken,
})
})
// POST /api/jellyfin/settings
router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record<string, string> = {}
@@ -26,11 +24,10 @@ router.post('/jellyfin/settings', (req, res) => {
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
})
// POST /api/jellyfin/test
router.post('/jellyfin/test', async (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) {
res.status(400).json({ ok: false, error: 'URL et token requis' }); return
res.status(400).json({ ok: false, error: 'URL et clé API requises' }); return
}
const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken)
res.json(result)
@@ -57,20 +54,19 @@ export async function runJellyfinSync(): Promise<{ created: number; skipped: num
createUser(jUser.Name, randomPass, 'user')
results.created++
results.users.push(jUser.Name)
logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`)
logger.info('jellyfin', `Compte créé pour « ${jUser.Name} » (synchronisation Jellyfin)`)
}
logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`)
logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`)
return results
}
// POST /api/jellyfin/sync
router.post('/jellyfin/sync', async (_req, res) => {
try {
const results = await runJellyfinSync()
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' })
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' })
}
})
+2 -2
View File
@@ -10,8 +10,8 @@ router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
})
router.post('/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}`))
logger.info('nfo-update', 'Vérification manuelle des mises à jour NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification manuelle des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
+6 -8
View File
@@ -19,8 +19,8 @@ router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la migration des IDs : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -40,13 +40,11 @@ router.post('/organize/recent/clear', requireAuth, (_req, res) => {
router.post('/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 }
if (!hash || !name || !save_path) { res.status(400).json({ error: 'Hash, nom et dossier du torrent requis' }); return }
try {
const seriesData = await loadCatalog()
// Récupère la progression par fichier depuis le client torrent.
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
// Progression par fichier, facultative (client injoignable toléré)
const files = await dispatchGetFiles(hash).catch(() => [])
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
@@ -55,8 +53,8 @@ router.post('/organize', requireAuth, async (req, res) => {
}
res.json(result)
} catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de l'import manuel de « ${name} » : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+36 -36
View File
@@ -7,7 +7,7 @@ const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
// ── Serveur Plex local : certificat non vérifié ───────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
@@ -16,7 +16,6 @@ function getServerFetch(serverUrl: string) {
})
}
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
...(options.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
if (!res.ok) {
logger.debug('plex', `plex.tv HTTP ${res.status} : ${await res.text().catch(() => '')}`)
throw new Error(`Plex a répondu HTTP ${res.status}`)
}
const text = await res.text()
return text ? JSON.parse(text) : {}
}
@@ -43,7 +45,6 @@ function mapServers(resources: any[]) {
}))
}
// POST /api/plex/connect — auth plex.tv + liste serveurs
router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
@@ -56,23 +57,22 @@ router.post('/plex/connect', requireAuth, async (req, res) => {
body : params.toString(),
})
const token = authData.authToken ?? authData.user?.authToken
if (!token) throw new Error('Token non reçu')
if (!token) throw new Error('Plex n\'a pas renvoyé de jeton d\'accès')
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
logger.info('plex', `Connexion Plex réussie pour ${username} (${servers.length} serveur(s))`)
res.json({ ok: true, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
if (msg.includes('401')) {
res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
res.status(401).json({ error: 'Identifiants incorrects, ou code de validation en deux étapes requis', requires2FA: true })
} else {
logger.error('plex', `Auth échouée : ${msg}`)
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
res.status(401).json({ error: 'Échec de l\'authentification : vérifiez vos identifiants' })
}
}
})
// POST /api/plex/oauth/start — démarre le flow OAuth
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
@@ -82,18 +82,17 @@ router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
})
const pinId = data.id
const pinCode = data.code
if (!pinId || !pinCode) throw new Error('Pin non reçu')
if (!pinId || !pinCode) throw new Error('Plex n\'a pas renvoyé de code PIN')
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
logger.info('plex', `OAuth démarré — pinId ${pinId}`)
logger.info('plex', `Connexion Plex démarrée (PIN ${pinId})`)
res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
logger.error('plex', `OAuth start échoué : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('plex', `Échec du démarrage de la connexion Plex : ${msg}`)
res.status(500).json({ error: msg })
}
})
// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId)
try {
@@ -102,22 +101,20 @@ router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
if (!token) { res.json({ ok: false, pending: true }); return }
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
logger.info('plex', `Connexion Plex réussie (${servers.length} serveur(s))`)
res.json({ ok: true, pending: false, token, servers })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
res.status(500).json({ error: msg })
}
})
// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
res.status(400).json({ error: 'Jeton Plex, serveur, nom et dossier de la bibliothèque requis' }); return
}
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
@@ -127,7 +124,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
let groupId: string | null = null
let agentSetupOk = false
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
// ── Étape 1 : enregistrer l'agent Fankai ─────────────────
try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
@@ -140,7 +137,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
steps.push({ step: 'provider', ok: true, message: `Agent Fankai enregistré (${agentIdentifier})` })
}
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
@@ -156,22 +153,22 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (group) {
groupId = String(group.id ?? '')
steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
steps.push({ step: 'group', ok: true, message: `Groupe d'agents prêt (ID : ${groupId})` })
agentSetupOk = true
}
}
} else {
const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré (HTTP ${status}, Plex antérieur à 1.43 ?) : configuration manuelle nécessaire` })
logger.warn('plex', `Configuration de l'agent Fankai : HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré : configuration manuelle nécessaire` })
logger.warn('plex', `Échec de la configuration de l'agent Fankai : ${msg}`)
}
// ── Étape 2 : Créer la bibliothèque ──────────────────────
// ── Étape 2 : créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({
type : 'show',
@@ -183,14 +180,17 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
if (!libRes.ok) {
logger.debug('plex', `Création de bibliothèque HTTP ${libRes.status} : ${await libRes.text().catch(() => '')}`)
throw new Error(`le serveur Plex a répondu HTTP ${libRes.status}`)
}
steps.push({ step: 'library', ok: true, message: `Bibliothèque « ${libraryName} » créée` })
logger.info('plex', `Bibliothèque « ${libraryName} » créée sur ${serverUrl}`)
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
logger.error('plex', `Création bibliothèque échouée : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'library', ok: false, message: `Échec de la création de la bibliothèque : ${msg}` })
logger.error('plex', `Échec de la création de la bibliothèque : ${msg}`)
res.status(500).json({ ok: false, steps, error: msg })
}
})
+14 -24
View File
@@ -1,7 +1,3 @@
/**
* API publique v1 — pour le plugin Jellyfin et intégrations tierces
* Auth : Bearer {apiToken} dans le header Authorization
*/
import { Router } from 'express'
import { findByApiToken } from '../users.js'
@@ -17,24 +13,23 @@ import type { Request, Response, NextFunction } from 'express'
const router = Router()
// ── Middleware auth API token ─────────────────────────────────
// ── Jeton d'API ───────────────────────────────────────────────
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Token requis (Authorization: Bearer <token>)' }); return
res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer <jeton>)' }); return
}
const user = findByApiToken(header.slice(7))
if (!user) {
res.status(401).json({ error: 'Token invalide' }); return
res.status(401).json({ error: 'Jeton d\'API invalide' }); return
}
req.user = user
next()
}
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
// Échange un token Jellyfin contre un token FanKarr
// Body: { jellyfinUserId, jellyfinToken }
// Échange un jeton Jellyfin contre un jeton FanKarr
router.post('/v1/auth/jellyfin', async (req, res) => {
const { jellyfinUserId, jellyfinToken } = req.body
if (!jellyfinUserId || !jellyfinToken) {
@@ -43,20 +38,19 @@ router.post('/v1/auth/jellyfin', async (req, res) => {
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
if (!info.valid) {
res.status(401).json({ error: info.error ?? 'Authentification Jellyfin échouée' }); return
res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
}
// Trouver l'utilisateur FanKarr correspondant (par username)
const users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
if (!fanUser) {
logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`)
logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`)
res.status(404).json({
error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`,
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`,
}); return
}
logger.info('public-api', `SSO Jellyfin réussi pour "${fanUser.username}"`)
logger.info('public-api', `Connexion via Jellyfin réussie pour « ${fanUser.username} »`)
res.json({
token : fanUser.apiToken,
username: fanUser.username,
@@ -70,7 +64,6 @@ router.get('/v1/auth/me', requireApiToken, (req, res) => {
})
// ── GET /api/v1/series/search?q= ─────────────────────────────
// Recherche dans le catalogue FanKai
router.get('/v1/series/search', requireApiToken, async (req, res) => {
const q = String(req.query.q ?? '').trim().toLowerCase()
try {
@@ -113,15 +106,15 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
res.json(results)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/series/:id ────────────────────────────────────
// Détail d'une série : saisons + épisodes (proxy FanKai)
// Relais de l'API Fankai
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
const id = Number(req.params.id)
if (isNaN(id)) { res.status(400).json({ error: 'id invalide' }); return }
if (isNaN(id)) { res.status(400).json({ error: 'Identifiant de série invalide' }); return }
try {
const [serieRaw, seasonsData] = await Promise.all([
fankaiGet(`/series/${id}`),
@@ -156,12 +149,11 @@ router.get('/v1/series/:id', requireApiToken, async (req, res) => {
seasons : seasonsWithEpisodes,
})
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── POST /api/v1/requests ─────────────────────────────────────
// Créer ou mettre à jour une demande
router.post('/v1/requests', requireApiToken, (req, res) => {
const { serieId, serieName, seasons, episodes } = req.body
if (!serieId || !serieName) {
@@ -179,7 +171,6 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
submittedEpisodes,
)
// Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
@@ -190,18 +181,17 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
if (isNew) request = approveRequest(request.id)
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
logger.warn('public-api', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
logger.warn('public-api', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/requests ──────────────────────────────────────
// Demandes de l'utilisateur authentifié
router.get('/v1/requests', requireApiToken, (req, res) => {
res.json(getRequestsForUser(req.user!.id))
})
+16 -31
View File
@@ -14,7 +14,6 @@ import { logger } from '../logger.js'
const router = Router()
// GET /api/requests — admin voit tout, user voit les siennes
router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
@@ -37,7 +36,6 @@ async function withScope(list: SerieRequest[]) {
}))
}
// GET /api/requests/pending-count — pour le widget dashboard
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
? getPendingCount()
@@ -45,11 +43,10 @@ router.get('/requests/pending-count', (req, res) => {
res.json({ count })
})
// POST /api/requests — créer ou mettre à jour une demande
router.post('/requests', (req, res) => {
const { serieId, serieName, seasons, episodes, torrentOverride } = req.body
if (!serieId || !serieName) {
res.status(400).json({ error: 'serieId et serieName requis' }); return
res.status(400).json({ error: 'Série manquante dans la demande' }); return
}
try {
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
@@ -63,7 +60,6 @@ router.post('/requests', (req, res) => {
submittedEpisodes,
)
// Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
@@ -74,7 +70,6 @@ router.post('/requests', (req, res) => {
if (isNew) request = approveRequest(request.id)
if (torrentOverride?.torrent_url || torrentOverride?.magnet) {
// L'utilisateur a sélectionné un torrent spécifique → l'envoyer directement
const url = torrentOverride.torrent_url ?? torrentOverride.magnet
dispatchDownload(url, {
infohash : torrentOverride.infohash ?? null,
@@ -82,24 +77,22 @@ router.post('/requests', (req, res) => {
file_index: torrentOverride.file_index ?? null,
file_path : torrentOverride.file_path ?? null,
}).catch(err =>
logger.warn('requests', `Auto-dl (override) échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
logger.warn('requests', `Échec du téléchargement automatique du torrent choisi pour « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else {
// Pas de torrent sélectionné → résolution automatique
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
}
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// PATCH /api/requests/:id — admin : approve / reject / complete
router.patch('/requests/:id', requireAdmin, async (req, res) => {
const { action, rejectionMessage } = req.body
try {
@@ -107,28 +100,23 @@ router.patch('/requests/:id', requireAdmin, async (req, res) => {
const id = String(req.params.id)
if (action === 'approve') {
request = approveRequest(id)
// Approbation manuelle par l'admin → toujours lancer le téléchargement
autoDownloadRequest(request).catch(err =>
logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
else if (action === 'complete') request = completeRequest(id)
else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return }
else { res.status(400).json({ error: 'Action inconnue (approve, reject ou complete)' }); return }
res.json(request)
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
/**
* Trouve et envoie au client torrent les fichiers correspondant à la demande.
* @param override - si fourni, utilise ces saisons/épisodes au lieu du merged de la demande
* (utile pour ne télécharger que ce qui vient d'être ajouté)
*/
/** Envoie les torrents de la demande ; override limite aux saisons et épisodes qui viennent d'être ajoutés. */
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> {
const serieData = await readSerieData(req.serieId)
if (!serieData) {
logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`)
logger.warn('requests', `Téléchargement automatique : aucune donnée de torrent pour la série ${req.serieId}`)
return
}
@@ -138,7 +126,7 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
if (toDownload.length === 0) {
logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`)
logger.warn('requests', `Téléchargement automatique : aucun torrent trouvé pour « ${req.serieName} »`)
return
}
@@ -147,16 +135,15 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const url = t.url ?? t.magnet
if (!url) continue
try {
await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
sent++
const results = await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
if (results.some(r => r.ok)) sent++
} catch (err) {
logger.warn('requests', `Auto-dl erreur torrent : ${err instanceof Error ? err.message : err}`)
logger.warn('requests', `Téléchargement automatique : erreur à l'envoi d'un torrent : ${err instanceof Error ? err.message : err}`)
}
}
logger.info('requests', `Auto-dl "${req.serieName}" — ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
logger.info('requests', `Téléchargement automatique de « ${req.serieName} » : ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
}
// DELETE /api/requests — admin : supprimer toutes les demandes
router.delete('/requests', requireAdmin, (_req, res) => {
const all = readRequests()
for (const r of all) {
@@ -166,22 +153,20 @@ router.delete('/requests', requireAdmin, (_req, res) => {
res.json({ deleted: all.length })
})
// DELETE /api/requests/:id/mine — retire l'utilisateur d'une demande en attente
router.delete('/requests/:id/mine', (req, res) => {
try {
res.json(withdrawRequest(String(req.params.id), req.user!.id))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// DELETE /api/requests/:id — admin seulement
router.delete('/requests/:id', requireAdmin, (req, res) => {
try {
deleteRequest(String(req.params.id))
res.json({ success: true })
} catch (err) {
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+7 -13
View File
@@ -6,57 +6,51 @@ import { loadCatalogStatus } from '../lib/serie-helpers.js'
const router = Router()
/** GET /api/rss-sync → liste des séries surveillées */
router.get('/rss-sync', requireAuth, (_req, res) => {
res.json(Object.values(loadSynced()))
})
/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (avant /:id) */
// Routes fixes à déclarer avant /:id
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
try {
const result = await runRssSync()
res.json({ ok: true, ...result })
} catch (err) {
logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('rss-sync', `Échec de la vérification manuelle des séries surveillées : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
const { series, complete } = await loadCatalogStatus()
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
})
/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && s.name)
if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return }
if (series.length === 0) { res.status(400).json({ error: 'Aucune série sélectionnée' }); return }
const { changed, map } = setSyncBulk(series, enabled)
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`)
logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} pour plusieurs séries : ${changed} série(s) modifiée(s) sur ${series.length}`)
res.json({ ok: true, changed, total: Object.keys(map).length })
})
/** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
res.json({ synced: isSynced(serieId) })
})
/** POST /api/rss-sync/:id → activer la surveillance */
router.post('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const serieName = String(req.body.serieName ?? '')
if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return }
if (!serieId || !serieName) { res.status(400).json({ error: 'Série manquante' }); return }
const map = setSync(serieId, serieName, true)
logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`)
logger.info('rss-sync', `Surveillance activée pour « ${serieName} » (série ${serieId})`)
res.json({ synced: true, total: Object.keys(map).length })
})
/** DELETE /api/rss-sync/:id → désactiver la surveillance */
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const map = setSync(serieId, '', false)
+5 -11
View File
@@ -17,7 +17,6 @@ import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router()
// Séries récemment ajoutées à la médiathèque
router.get('/library/recent', requireAuth, (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
res.json(recentImportsBySerie().slice(0, limit))
@@ -52,8 +51,8 @@ router.get('/series', requireAuth, async (_req, res) => {
}
})})
} catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement des séries : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -80,7 +79,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
}
}
if (serieData) {
// Lookup rapide hash → torrent pack (intégrale ou saison)
// Index hash : pack (intégrale ou saison)
const torrentByHash = new Map<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
for (const t of (serieData.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
@@ -126,9 +125,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organizedEpisodeIds.add(ep.id)
}
}
// Fallback : si l'épisode n'a pas de torrent individuel,
// on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
// On ajoute TOUTES les entrées valides (un pack par path unique).
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set<string>()
for (const pathEntry of (ep.paths ?? [])) {
@@ -175,8 +171,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organized: organizedEpisodeIds.has(ep.id),
}
})
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
// sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
@@ -201,8 +195,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec du chargement de la série ${id} : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+33 -23
View File
@@ -15,7 +15,17 @@ import { workerRunning, lastScan } from '../organize.js'
const router = Router()
// ── Logs ───────────────────────────────────────────────────────
const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
function readDirError(dirPath: string, err: unknown): string {
const code = (err as NodeJS.ErrnoException)?.code
if (code === 'ENOENT') return `Dossier introuvable : ${dirPath}`
if (code === 'EACCES' || code === 'EPERM') return `Accès refusé au dossier : ${dirPath}`
if (code === 'ENOTDIR') return `Ce chemin n'est pas un dossier : ${dirPath}`
return `Dossier inaccessible : ${dirPath}`
}
// ── Journaux ───────────────────────────────────────────────────
router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
@@ -25,7 +35,7 @@ router.get('/logs', requireAuth, (req, res) => {
router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
logger.info('api', 'Journaux effacés')
res.json({ ok: true })
})
@@ -39,7 +49,7 @@ router.get('/system', requireAuth, (_req, res) => {
router.post('/system/check-paths', requireAuth, (req, res) => {
const { mediaPath, completePath } = req.body ?? {}
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) {
res.status(400).json({ error: 'mediaPath requis' }); return
res.status(400).json({ error: 'Chemin de la médiathèque requis' }); return
}
res.json(checkPaths(mediaPath, completePath))
})
@@ -54,7 +64,7 @@ router.get('/version', (_req, res) => {
}
})
// ── Parcours fichiers ──────────────────────────────────────────
// ── Parcours des dossiers ──────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
@@ -74,7 +84,7 @@ router.get('/browse', requireAuth, (req, res) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
// Sous Windows, remonter à '/' depuis la racine d'un lecteur (ex. C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
@@ -82,13 +92,13 @@ router.get('/browse', requireAuth, (req, res) => {
parent = '/'
} else {
const up = path.dirname(dirPath)
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
parent = up === dirPath ? null : up // dirname de C:\ renvoie C:\ : pas de parent
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -120,8 +130,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files, exists: true })
} catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -139,19 +149,19 @@ router.post('/update', requireAuth, async (req, res) => {
try {
cacheClear()
logger.info('api', force
? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés'
: 'Cache GitHub vidé — rechargement forcé')
? 'Resynchronisation complète du catalogue (caches ignorés), noms de fichiers recalculés'
: 'Cache GitHub vidé : rechargement du catalogue')
const availableIds = await readAvailable(force)
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
if (!Array.isArray(availableIds)) throw new Error('Catalogue GitHub invalide (available.json)')
logger.info('api', `Catalogue rechargé : ${availableIds.length} séries disponibles`)
if (force) {
const seriesData = await loadCatalog(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
logger.info('api', `Resynchronisation terminée : ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return
}
@@ -162,14 +172,14 @@ router.post('/update', requireAuth, async (req, res) => {
const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
logger.info('api', `Renommage après synchronisation : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
logger.warn('api', `Échec du renommage après synchronisation : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de la synchronisation du catalogue : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
@@ -180,17 +190,17 @@ router.get('/scan', requireAuth, (_req, res) => {
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
logger.info('api', 'Scan médiathèque manuel lancé')
logger.info('api', 'Analyse manuelle de la médiathèque lancée')
const seriesData = await loadCatalog()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
logger.error('api', `Échec de l'analyse de la médiathèque : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
// ── Debug ──────────────────────────────────────────────────────
// ── Diagnostic ─────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => {
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
+8 -8
View File
@@ -18,8 +18,8 @@ router.get('/torrent-clients', requireAuth, (_req, res) => {
router.post('/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 }
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
@@ -31,8 +31,8 @@ router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.put('/torrent-clients/:uuid', 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 }
if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
@@ -51,9 +51,9 @@ router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
if (!type || !config) { res.status(400).json({ error: 'Type et réglages du client requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
if (!driver) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
@@ -71,7 +71,7 @@ router.post('/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 }
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.test(client.config))
})
@@ -79,7 +79,7 @@ router.get('/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 }
if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.healthcheck(client.config))
})
+7 -13
View File
@@ -6,60 +6,54 @@ import {
const router = Router()
// GET /api/users
router.get('/users', (_req, res) => {
res.json(readUsers().map(safeUser))
})
// POST /api/users
router.post('/users', (req, res) => {
const { username, password, role } = req.body
if (!username || !password) {
res.status(400).json({ error: 'username et password requis' }); return
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// PATCH /api/users/:id
router.patch('/users/:id', (req, res) => {
const { username, password, role } = req.body
try {
const user = updateUser(req.params.id, { username, password, role })
res.json(safeUser(user))
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// DELETE /api/users/:id
router.delete('/users/:id', (req, res) => {
// Empêche l'admin de se supprimer lui-même
if (req.user?.id === req.params.id) {
res.status(400).json({ error: 'Impossible de supprimer son propre compte' }); return
res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); return
}
try {
deleteUser(req.params.id)
res.json({ success: true })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// POST /api/users/:id/regenerate-token
router.post('/users/:id/regenerate-token', (req, res) => {
try {
const token = regenerateApiToken(req.params.id)
res.json({ apiToken: token })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
+2 -12
View File
@@ -1,10 +1,3 @@
/**
* secret.ts
* =========
* Génère et persiste le JWT secret dans data/secret.key
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
*/
import fs from 'fs'
import path from 'path'
@@ -18,25 +11,22 @@ function generateSecret(): string {
}
function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET
}
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret
}
// 3. Génération + sauvegarde
const secret = generateSecret()
try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
console.log(`[auth] Secret JWT généré dans ${SECRET_PATH}`)
} catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err)
console.error('[auth] Impossible d\'enregistrer le secret JWT :', err)
}
return secret
+4 -4
View File
@@ -13,10 +13,10 @@ export interface Settings {
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null
+5 -3
View File
@@ -49,7 +49,7 @@ export function checkDirectory(input: string, { allowRoot = true } = {}): PathCh
const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
if (!raw) return { ...result, error: 'Chemin vide' }
if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez une barre après la lettre du lecteur' }
if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez « \\ » après la lettre du lecteur (ex. C:\\)' }
if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
result.resolved = path.resolve(raw)
@@ -72,7 +72,9 @@ export function checkDirectory(input: string, { allowRoot = true } = {}): PathCh
result.writable = true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
result.error = `Écriture impossible${code ? ` (${code})` : ''}`
result.error = code === 'EACCES' || code === 'EPERM'
? `Écriture impossible : permissions insuffisantes (${code})`
: `Écriture impossible${code ? ` (${code})` : ''}`
} finally {
removeQuietly(probe)
}
@@ -134,7 +136,7 @@ export function checkPaths(mediaPath: string, completePath?: string) {
let hardlink: HardlinkCheck
if (!hasComplete) {
hardlink = { tested: false, ok: false, message: 'Renseignez le dossier de téléchargements pour tester le hardlink' }
hardlink = { tested: false, ok: false, message: 'Renseignez le dossier des téléchargements pour tester le hardlink' }
} else if (!valid(media) || !valid(complete)) {
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
} else if (relation === 'same') {
+28 -59
View File
@@ -1,20 +1,10 @@
/**
* Deluge Web JSON-RPC driver
*
* Auth : POST {url}/json → auth.login(password) → cookie _session_id
* RPC : POST {url}/json avec { method, params, id } + Cookie header
* States : "Downloading" | "Seeding" | "Paused" | "Checking"
* "Error" | "Queued" | "Allocating" | "Moving"
* Progress : float 0–100 (déjà en pourcentage, pas 0–1)
*/
/** Deluge via l'API JSON-RPC de l'interface web. Progression en pourcentage (0–100). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── Session cache ─────────────────────────────────────────────────────────────
// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
// ─── Sessions ──────────────────────────────────────────────────────────────────
interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>()
@@ -23,14 +13,14 @@ function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}`
}
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
// ─── HTTP ──────────────────────────────────────────────────────────────────────
function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
}
function parseCookie(raw: string): string {
// "set-cookie: _session_id=abc123; Path=/; HttpOnly" → "_session_id=abc123"
// "_session_id=abc123; Path=/; HttpOnly" devient "_session_id=abc123"
return raw.split(';')[0].trim()
}
@@ -51,14 +41,11 @@ async function delugeRPC(
body: JSON.stringify({ method, params, id: 1 }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
}
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
if (json.error) {
throw new Error(`Deluge RPC : ${json.error.message ?? JSON.stringify(json.error)}`)
throw new Error(`Erreur Deluge : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
}
const setCookie = res.headers.get('set-cookie')
@@ -68,7 +55,7 @@ async function delugeRPC(
}
}
// ─── Login ─────────────────────────────────────────────────────────────────────
// ─── Connexion ─────────────────────────────────────────────────────────────────
async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config)
@@ -78,22 +65,19 @@ async function delugeLogin(config: ClientConfig): Promise<string> {
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
}
if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`)
if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)')
if (json.error) throw new Error(`Connexion à Deluge refusée : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
if (!json.result) throw new Error('Connexion à Deluge refusée : mot de passe incorrect ?')
const setCookie = res.headers.get('set-cookie')
if (!setCookie) throw new Error('Deluge : pas de cookie de session reçu après login')
if (!setCookie) throw new Error('Échec de la connexion à Deluge : aucun cookie de session reçu')
return parseCookie(setCookie)
}
// ─── Call avec gestion de session ─────────────────────────────────────────────
// ─── Appel avec gestion de session ────────────────────────────────────────────
async function delugeCall(
config : ClientConfig,
@@ -103,7 +87,6 @@ async function delugeCall(
const url = delugeUrl(config)
const key = sessionKey(config)
// Récupère ou rafraîchit la session
let entry = _sessions.get(key)
if (!entry || Date.now() >= entry.expires) {
const cookie = await delugeLogin(config)
@@ -113,7 +96,6 @@ async function delugeCall(
try {
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
// Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) {
entry.cookie = newCookie
entry.expires = Date.now() + 30 * 60_000
@@ -121,7 +103,6 @@ async function delugeCall(
return result
} catch (err) {
const msg = err instanceof Error ? err.message : ''
// Session expirée → relogin unique
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
_sessions.delete(key)
const cookie = await delugeLogin(config)
@@ -134,7 +115,7 @@ async function delugeCall(
}
}
// ─── Mapping état ──────────────────────────────────────────────────────────────
// ─── États ─────────────────────────────────────────────────────────────────────
function mapState(state: string): TorrentInfo['state'] {
switch (state) {
@@ -175,9 +156,7 @@ function flattenDelugeFiles(
return result
}
// ─── Sélection de fichier (asynchrone, best-effort) ──────────────────────────
// Attend que les métadonnées soient disponibles (~60 s max) puis applique
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
// ─── Sélection de fichier (en arrière-plan) ──────────────────────────────────
async function applyFilePriority(
config : ClientConfig,
@@ -193,14 +172,14 @@ async function applyFilePriority(
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
logger.info('deluge', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('deluge', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
logger.warn('deluge', `Timeout : priorité fichier non appliquée pour ${hash.slice(0, 8)}…`)
logger.warn('deluge', `Délai dépassé : priorité de fichier non appliquée pour ${hash.slice(0, 8)}…`)
}
// ─── Driver ───────────────────────────────────────────────────────────────────
// ─── Client ───────────────────────────────────────────────────────────────────
const delugeDriver: TorrentClientDriver = {
definition: {
@@ -209,15 +188,14 @@ const delugeDriver: TorrentClientDriver = {
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie (Label plugin)', 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 },
{ key: 'category', label: 'Catégorie (plugin Label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
// ── Test ───────────────────────────────────────────────────────────────────
async test(config) {
try {
_sessions.delete(sessionKey(config))
@@ -226,16 +204,14 @@ const delugeDriver: TorrentClientDriver = {
logger.info('deluge', `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('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('deluge', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
// ── Healthcheck ────────────────────────────────────────────────────────────
async healthcheck(config) {
try {
// daemon.get_version() retourne la version du daemon Deluge
const version = await delugeCall(config, 'daemon.get_version')
return { online: true, version: String(version ?? 'inconnue') }
} catch {
@@ -243,15 +219,13 @@ const delugeDriver: TorrentClientDriver = {
}
},
// ── List ───────────────────────────────────────────────────────────────────
async list(config, category?) {
const keys = [
'name', 'state', 'progress', 'total_size', 'total_done',
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
'eta', 'save_path', 'label',
]
// filter_dict vide → tous les torrents ; le filtrage par label se fait en JS
// pour ne pas dépendre du Label plugin côté serveur.
// Tous les torrents, filtrés par label ici pour ne pas dépendre du plugin Label
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
return Object.entries(raw)
@@ -273,7 +247,6 @@ const delugeDriver: TorrentClientDriver = {
}))
},
// ── Add ────────────────────────────────────────────────────────────────────
async add(config, url, options?: DownloadOptions) {
const addOpts: Record<string, unknown> = {}
if (config.savePath) addOpts['download_location'] = String(config.savePath)
@@ -282,36 +255,32 @@ const delugeDriver: TorrentClientDriver = {
if (url.startsWith('magnet:')) {
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
logger.info('deluge', `Magnet ajouté → ${hash?.slice(0, 8) ?? '?'}…`)
logger.info('deluge', `Magnet ajouté (hash ${hash?.slice(0, 8) ?? '?'}…)`)
} else {
// Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent : ${torrentRes.status}`)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const buf = await torrentRes.arrayBuffer()
const b64 = Buffer.from(buf).toString('base64')
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
logger.info('deluge', `.torrent uploadé → ${hash?.slice(0, 8) ?? '?'}…`)
logger.info('deluge', `Fichier .torrent envoyé (hash ${hash?.slice(0, 8) ?? '?'}…)`)
}
// Label plugin — best-effort (silencieux si plugin absent)
// Plugin Label facultatif : erreurs ignorées
if (config.category && hash) {
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
}
// Sélection de fichier — asynchrone, best-effort
if (options?.file_index != null && hash) {
applyFilePriority(config, hash, options.file_index).catch(() => {})
}
},
// ── Remove ─────────────────────────────────────────────────────────────────
async remove(config, hash, deleteFiles = false) {
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
// ── Get files ──────────────────────────────────────────────────────────────
async getFiles(config, hash) {
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
if (!data?.contents) return []
+45 -73
View File
@@ -1,8 +1,3 @@
/**
* Torrent Client Registry
* =======================
* Interface commune + registry de tous les clients disponibles.
*/
import { randomUUID } from 'crypto'
import fs from 'fs'
@@ -29,8 +24,7 @@ export interface TorrentClientDefinition {
id : string
label : string
fields : FieldDef[]
/** Si true, dispatchList filtre les résultats pour n'inclure que les
* torrents ajoutés via FanKarr (clients sans catégories natives). */
/** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
filterByManagedHashes?: boolean
}
@@ -43,9 +37,9 @@ export interface SavedClient {
export interface TorrentFileProgress {
index : number
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
name ?: string // chemin relatif dans le torrent (ex. "Pack S1/Episode1.mkv"), selon le client
progress: number // 0–1
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
priority: number // 0 = non téléchargé, 1 = normale, 6 = haute, 7 = maximale
}
export interface TorrentInfo {
@@ -68,8 +62,8 @@ export interface TorrentInfo {
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent
infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
magnet ?: string | null
}
export interface TorrentClientDriver {
@@ -83,9 +77,7 @@ export interface TorrentClientDriver {
}
// ─── Certificats TLS ──────────────────────────────────────────────────────────
// Option "ignoreCertificateErrors" (par client) : accepte les certificats
// auto-signés/invalides. Node → Agent undici (cf. plex.ts) ; Bun → option
// `tls` de son fetch natif (l'Agent undici n'y est pas honoré).
// ignoreCertificateErrors : Agent undici sous Node, option tls du fetch sous Bun
let _insecureAgent: Agent | null = null
@@ -99,14 +91,13 @@ export function ignoreCertificateErrors(config: ClientConfig): boolean {
return v === true || v === 'true' || v === 1
}
// Option `tls` propre au fetch de Bun, absente des types standards.
// Option tls propre à Bun, absente des types standards
interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean }
}
const isBun = 'bun' in process.versions
/** fetch du client : accepte les certificats invalides si l'option est active. */
export function clientFetch(
config : ClientConfig,
url : string,
@@ -120,13 +111,13 @@ export function clientFetch(
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
// ─── Registre ─────────────────────────────────────────────────────────────────
const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver)
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
logger.debug('torrent-clients', `Type de client enregistré : ${driver.definition.label}`)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -137,15 +128,12 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
// ─── Persistence ──────────────────────────────────────────────────────────────
// ─── Stockage ─────────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
// ─── Managed tasks ─────────────────────────────────────────────────────────────
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
// Utilisé par dispatchList pour filtrer les résultats des clients sans
// catégories natives (ex: Synology Download Station).
// Structure : { clientUuid: [infohash, ...] }
// ─── Torrents suivis ──────────────────────────────────────────────────────────
// Hashes ajoutés par FanKarr, par client : filtre les clients sans catégories (Synology)
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
@@ -163,14 +151,12 @@ function saveManagedMap(data: Record<string, string[]>) {
} catch {}
}
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
function getManagedSet(clientUuid: string): Set<string> | null {
const list = loadManagedMap()[clientUuid]
if (!list || list.length === 0) return null
return new Set(list)
}
/** Enregistre un infohash comme géré pour un client (idempotent). */
export function trackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) data[clientUuid] = []
@@ -181,7 +167,6 @@ export function trackManagedHash(clientUuid: string, infohash: string) {
}
}
/** Retire un infohash de la liste gérée pour un client. */
export function untrackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
@@ -190,7 +175,6 @@ export function untrackManagedHash(clientUuid: string, infohash: string) {
saveManagedMap(data)
}
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
function clearManagedHashes(clientUuid: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
@@ -221,7 +205,7 @@ export function addClient(name: string, type: string, config: ClientConfig): Sav
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
return client
}
@@ -232,7 +216,7 @@ export function removeClient(uuid: string): boolean {
if (filtered.length === clients.length) return false
saveClients(filtered)
clearManagedHashes(uuid)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
return true
}
@@ -246,7 +230,7 @@ export function updateClient(uuid: string, name: string, type: string, config: C
if (index === -1) return null
clients[index] = { ...clients[index], name, type, config }
saveClients(clients)
logger.info('torrent-clients', `Client modifié : "${name}" (${type})`)
logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
return clients[index]
}
@@ -262,8 +246,8 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
// ─── Backoff par client ───────────────────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
// ─── Pause après échecs de connexion ──────────────────────────────────────────
// Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
interface BackoffEntry {
failCount : number
@@ -272,7 +256,6 @@ interface BackoffEntry {
const _backoff = new Map<string, BackoffEntry>()
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean {
@@ -286,7 +269,7 @@ function cooldownRemaining(uuid: string): string {
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : ''
return m > 0 ? `${m} min` : ''
}
function registerFailure(uuid: string, clientName: string) {
@@ -296,14 +279,14 @@ function registerFailure(uuid: string, clientName: string) {
entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry)
const min = Math.round(delay / 60_000)
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
logger.warn('torrent-clients', `Client « ${clientName} » en pause ${min} min après ${entry.failCount} échec(s) de connexion`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
// Exposé pour l'API de status
// Exposé pour l'API d'état
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
@@ -312,11 +295,9 @@ export function getClientCooldowns(): Record<string, { failCount: number; nextRe
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// ─── Correspondance des chemins ───────────────────────────────────────────────
// 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
// Remplace le préfixe remotePath par localPath (ex. /downloads/x devient /mnt/nas/downloads/x)
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
@@ -325,13 +306,13 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
// ─── Envoi d'un téléchargement ────────────────────────────────────────────────
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients()
if (clients.length === 0) {
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
logger.warn('torrent-clients', 'Aucun client torrent configuré : téléchargement impossible')
return []
}
@@ -340,21 +321,21 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) {
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
logger.error('torrent-clients', `Type de client « ${client.type} » non pris en charge (client « ${client.name} »)`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Type de client « ${client.type} » non pris en charge` })
continue
}
try {
await driver.add(client.config, url, options)
if (options?.infohash) {
trackManagedHash(client.uuid, options.infohash)
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
logger.debug('torrent-clients', `Torrent ${options.infohash.slice(0, 8)}… suivi pour « ${client.name} »`)
}
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
logger.info('torrent-clients', `Téléchargement envoyé à « ${client.name} »`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Échec de l'envoi à « ${client.name} » : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
}
}
@@ -362,7 +343,7 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
return results
}
// ─── Dispatch remove ───────────────────────────────────────────────────────────
// ─── Suppression ──────────────────────────────────────────────────────────────
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients()
@@ -372,18 +353,18 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
if (!driver) continue
try {
await driver.remove(client.config, hash, deleteFiles)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de « ${client.name} »${deleteFiles ? ' (avec fichiers)' : ''}`)
untrackManagedHash(client.uuid, hash)
found = true
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('torrent-clients', `Impossible de supprimer le torrent de « ${client.name} » : ${msg}`)
}
}
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients torrent' }
}
// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
// Titre normalisé vers infohash, pour retrouver les torrents Synology
function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
const index = new Map<string, string>()
for (const [hash, title] of Object.entries(infohashMap)) {
@@ -406,7 +387,7 @@ export async function dispatchList(
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
logger.debug('torrent-clients', `Client « ${client.name} » en pause, nouvel essai dans ${remaining}`)
continue
}
@@ -425,20 +406,16 @@ export async function dispatchList(
? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
// en matchant le titre du torrent dans la infohashMap
// Synology (dbid_xxx) : retrouver l'infohash par le titre
let hash = t.hash
if (titleIndex && t.hash.startsWith('dbid_')) {
const resolved = titleIndex.get(t.name.toLowerCase().trim())
if (resolved) {
logger.debug('torrent-clients', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
logger.debug('torrent-clients', `Hash Synology résolu : « ${t.name} » (${resolved.slice(0, 8)}…)`)
hash = resolved
}
}
// Filtre par hashes gérés pour les clients sans catégories natives.
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
if (driver.definition.filterByManagedHashes) {
const managed = getManagedSet(client.uuid)
if (managed && !managed.has(hash)) continue
@@ -447,14 +424,11 @@ export async function dispatchList(
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
const isAuthError = msg.toLowerCase().includes('login') ||
msg.toLowerCase().includes('auth') ||
msg.toLowerCase().includes('ban') ||
msg.toLowerCase().includes('forbidden') ||
msg.toLowerCase().includes('401') ||
msg.toLowerCase().includes('403')
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Impossible de récupérer la liste des torrents de « ${client.name} » : ${msg}`)
const lower = msg.toLowerCase()
const isAuthError = ['login', 'auth', 'ban', 'forbidden', '401', '403', 'connexion refusée', 'refusé la connexion',
'connexion à deluge refusée', 'clé api', 'échec de la connexion'].some(k => lower.includes(k))
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
@@ -462,9 +436,7 @@ export async function dispatchList(
return results
}
// ─── Dispatch file progress ────────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
// ─── Progression par fichier ──────────────────────────────────────────────────
export async function dispatchGetFiles(
hash : string,
+39 -62
View File
@@ -1,14 +1,10 @@
/**
* qBittorrent Driver
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
/** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
// peuvent wrapper la réponse en JSON — on accepte les deux
// Réponse « Ok. », ou JSON derrière certains reverse proxies
function isQbAddSuccess(text: string): boolean {
if (text.trim() === 'Ok.') return true
try {
@@ -27,7 +23,6 @@ function mapState(state: string): TorrentInfo['state'] {
return 'unknown'
}
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
@@ -48,7 +43,7 @@ async function qbLogin(config: ClientConfig): Promise<string> {
})
const text = await res.text()
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
if (!res.ok) throw new Error(`Échec de la connexion : ${text.trim() || `HTTP ${res.status}`}`)
if (text.trim() === 'Fails.') throw new Error('Identifiants incorrects (vérifiez le nom d\'utilisateur et le mot de passe)')
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
@@ -69,16 +64,11 @@ async function qbTorrentExists(config: ClientConfig, authHeaders: Record<string,
}
}
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* puis applique les priorités (désactive tout sauf la cible).
*/
async function qbApplyFilePriority(
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
@@ -91,13 +81,10 @@ async function qbApplyFilePriority(
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
throw new Error(`Fichier n° ${fileIndex} hors limites : le torrent contient ${files.length} fichiers`)
}
// Si tous les fichiers sont à priorité normale (état initial du torrent),
// on applique le filtrage complet : on désactive tout sauf la cible.
// Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
// on se contente d'activer la nouvelle cible sans toucher aux autres.
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
@@ -107,37 +94,35 @@ async function qbApplyFilePriority(
if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
if (!r.ok) { logger.warn('qbittorrent', `Priorités non appliquées (HTTP ${r.status}), nouvel essai`); continue }
}
}
// Activer le fichier cible (dans tous les cas)
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
})
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
if (!r2.ok) { logger.warn('qbittorrent', `Priorité du fichier cible non appliquée (HTTP ${r2.status}), nouvel essai`); continue }
// Vérification : le fichier cible est bien à priorité > 0
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
logger.warn('qbittorrent', `Fichier n° ${fileIndex} toujours à priorité 0 après application, nouvel essai`)
continue
}
}
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('qbittorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const QB: TorrentClientDriver = {
@@ -145,14 +130,14 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent',
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', 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 },
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'apiKey', label: 'Clé API (qBittorrent 5.2 ou plus)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', 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 de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -165,8 +150,8 @@ const QB: TorrentClientDriver = {
logger.info('qbittorrent', `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('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('qbittorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -179,11 +164,11 @@ const QB: TorrentClientDriver = {
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
if (version.startsWith('<')) throw new Error('Réponse HTML reçue — clé API invalide ou non supportée par cette version de qBittorrent')
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
if (version.startsWith('<')) throw new Error('Réponse HTML reçue : clé API invalide ou non prise en charge par cette version de qBittorrent')
logger.debug('qbittorrent', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('qbittorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -195,10 +180,9 @@ const QB: TorrentClientDriver = {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
if (!res.ok) throw new Error(`Liste des torrents inaccessible (HTTP ${res.status})`)
const data: any[] = await res.json()
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => {
try {
@@ -255,7 +239,6 @@ const QB: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) {
const authH = await qbAuth(config)
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
@@ -265,15 +248,15 @@ const QB: TorrentClientDriver = {
if (hash) {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
// Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
// Hashes présents avant l'ajout, pour repérer le nouveau torrent si le hash est inconnu
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
@@ -282,10 +265,7 @@ const QB: TorrentClientDriver = {
} catch {}
}
// Stratégie d'ajout selon le type de lien :
// magnet link → URLSearchParams (application/x-www-form-urlencoded), pas de binaire
// .torrent URL → FormData multipart obligatoire pour uploader le blob binaire
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
// .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
const isMagnet = url.startsWith('magnet:')
let res: Response
@@ -299,10 +279,9 @@ const QB: TorrentClientDriver = {
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
} else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
logger.debug('qbittorrent', `Téléchargement du fichier .torrent par FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
const form = new FormData()
if (config.category) form.append('category', String(config.category))
@@ -311,16 +290,15 @@ const QB: TorrentClientDriver = {
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
logger.info('qbittorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
// Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
logger.info('qbittorrent', `Torrent ajouté sans hash connu : recherche dans la liste (fichier n° ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
@@ -332,18 +310,17 @@ const QB: TorrentClientDriver = {
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}…, sélection du fichier n° ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex)
return
} catch {}
}
logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
logger.warn('qbittorrent', `Hash introuvable : impossible de sélectionner le fichier n° ${fileIndex}`)
})().catch(() => {})
}
return
}
// Pas de sélection de fichier → ajout normal (URLSearchParams, pas de binaire)
const params = new URLSearchParams()
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
@@ -354,8 +331,8 @@ const QB: TorrentClientDriver = {
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
@@ -366,7 +343,7 @@ const QB: TorrentClientDriver = {
body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
+16 -37
View File
@@ -1,17 +1,4 @@
/**
* Real-Debrid Driver
*
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
* puis les fichiers sont accessibles via des liens directs (HTTPS).
*
* Workflow FanKarr :
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
* 3. remove() → DELETE /torrents/delete/{id}
*
* Le save_path de chaque torrent est construit à partir du localPath configuré
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/
/** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js'
@@ -37,9 +24,10 @@ async function rdFetch(
body : body,
})
if (res.status === 204) return null
if (res.status === 401 || res.status === 403) throw new Error('Clé API Real-Debrid invalide ou compte non premium')
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
const data = await res.json().catch(() => null)
throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
}
return res.json()
}
@@ -67,18 +55,18 @@ const rdDriver: TorrentClientDriver = {
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
{ key: 'localPath', label: 'Dossier où Real-Debrid est monté (rclone, WebDAV…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
logger.info('real-debrid', `Connexion réussie (utilisateur ${user.username})`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -86,7 +74,7 @@ const rdDriver: TorrentClientDriver = {
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
return { online: true, version: `Compte ${user.username}` }
} catch {
return { online: false }
}
@@ -98,13 +86,12 @@ const rdDriver: TorrentClientDriver = {
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
// Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur Real-Debrid`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
@@ -125,11 +112,10 @@ const rdDriver: TorrentClientDriver = {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
logger.info('real-debrid', `Magnet ajouté (ID ${torrentId})`)
} else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
@@ -138,15 +124,13 @@ const rdDriver: TorrentClientDriver = {
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
if (!uploadRes.ok) throw new Error(`Échec de l'envoi du fichier .torrent (HTTP ${uploadRes.status})`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
logger.info('real-debrid', `Fichier .torrent envoyé (ID ${torrentId})`)
}
// Sélection des fichiers
if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
@@ -164,16 +148,13 @@ const rdDriver: TorrentClientDriver = {
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
logger.info('real-debrid', `Fichier n° ${options.file_index} sélectionné pour le torrent Real-Debrid ${torrentId}`)
}
} else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
@@ -195,10 +176,9 @@ const rdDriver: TorrentClientDriver = {
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
// save_path virtuel : dossier monté et nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
@@ -228,7 +208,6 @@ const rdDriver: TorrentClientDriver = {
},
async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
+28 -52
View File
@@ -1,15 +1,10 @@
/**
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
// ─── XML-RPC ──────────────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>`
@@ -71,21 +66,20 @@ async function rpcCall(
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await clientFetch(config, url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`rTorrent a répondu HTTP ${res.status} : vérifiez l'URL et le chemin RPC`)
const text = await res.text()
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
// Pas de DOMParser sous Node : analyse XML minimale
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
// Parse simplifié pour les types courants
// Types courants uniquement
const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
@@ -108,7 +102,6 @@ function parseInnerValue(inner: string): any {
return inner
}
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : ClientConfig,
view : string,
@@ -128,28 +121,20 @@ async function rtTorrentExists(config: ClientConfig, hash: string): Promise<bool
}
}
/**
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/
/** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
async function rtApplyFilePriority(
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
// 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300))
try {
// Récupérer taille ET priorité courante de chaque fichier
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue
// Si tous les fichiers sont à priorité normale (état initial),
// on filtre tout sauf la cible. Sinon on active juste la cible.
// Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) {
@@ -158,20 +143,17 @@ async function rtApplyFilePriority(
} else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0])
}
// Si pas initial state : on ne touche pas aux autres fichiers
}
logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
// ─── États ────────────────────────────────────────────────────
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
@@ -187,12 +169,12 @@ const RT: TorrentClientDriver = {
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie (label)', 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 },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -200,11 +182,11 @@ const RT: TorrentClientDriver = {
async test(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} (version ${version})`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -212,10 +194,10 @@ const RT: TorrentClientDriver = {
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
logger.debug('rtorrent', `Client en ligne (version ${version})`)
return { online: true, version: String(version) }
} catch (err) {
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('rtorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -230,8 +212,8 @@ const RT: TorrentClientDriver = {
'd.complete',
'd.size_bytes',
'd.bytes_done',
'd.up.total', // total uploadé
'd.ratio', // ratio x1000
'd.up.total',
'd.ratio', // ratio × 1000
'd.down.rate',
'd.up.rate',
'd.left_bytes',
@@ -241,7 +223,6 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods)
// Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map<string, any[]>()
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
@@ -301,42 +282,37 @@ const RT: TorrentClientDriver = {
const exists = await rtTorrentExists(config, hash)
if (exists) {
// Torrent déjà présent → mettre à jour la priorité directement (async OK)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
// Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
// On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
// Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('rtorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
// Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
logger.info('rtorrent', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const h = hash.toUpperCase()
if (deleteFiles) {
// Supprimer les fichiers puis effacer
await rpcCall(config, 'd.delete_tied', [h])
await rpcCall(config, 'd.erase', [h])
} else {
+60 -49
View File
@@ -1,6 +1,3 @@
/**
* Synology Download Station Driver
*/
import fs from 'fs'
import path from 'path'
@@ -9,18 +6,13 @@ import { clientFetch } from './index.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
// ─── Cache URI → infohash ──────────────────────────────────────────────────────
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
// On persiste l'association URI → infohash au moment du add() pour la réutiliser
// dans list() via le champ additional.detail.uri de chaque tâche.
// ─── Cache URI et infohash ─────────────────────────────────────────────────────
// DSM anciens : pas de hash BT dans l'API, on mémorise l'URI et son infohash à l'ajout
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
// même quand detail.hash et detail.uri sont vides.
// ─── Cache hash BT et ID Synology (en mémoire) ────────────────────────────────
// Hash BT vers dbid_X, pour que remove() et getFiles() retrouvent la tâche
const _hashToSynoId = new Map<string, string>()
function loadUriHashMap(): Record<string, string> {
@@ -38,7 +30,7 @@ const _uriToHash: Map<string, string> = (() => {
function storeUriHash(uri: string, hash: string) {
const key = uri.toLowerCase()
if (_uriToHash.get(key) === hash) return // déjà connu, pas besoin d'écrire
if (_uriToHash.get(key) === hash) return
_uriToHash.set(key, hash)
try {
const map = loadUriHashMap()
@@ -52,24 +44,24 @@ function resolveHash(detailHash: string, detailUri: string, taskId: string): str
// 1. Hash direct depuis l'API Synology (DSM récent)
if (detailHash.length > 0) return detailHash.toLowerCase()
// 2. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
// 2. Hash extrait du magnet de detail.uri
if (detailUri.startsWith('magnet:')) {
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
if (m) return m[1].toLowerCase()
}
// 3. Notre cache URI → infohash (stocké au moment du add())
// 3. Cache URI mémorisé à l'ajout
if (detailUri) {
const cached = _uriToHash.get(detailUri.toLowerCase())
if (cached) return cached
}
// 4. Fallback : ID Synology natif (déjà au format "dbid_X")
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
// 4. À défaut : ID Synology natif (dbid_X)
logger.debug('synology-ds', `Hash BT non résolu, ID Synology utilisé : ${taskId}`)
return String(taskId ?? '').toLowerCase()
}
// ─── State mapping ─────────────────────────────────────────────────────────────
// ─── États ─────────────────────────────────────────────────────────────────────
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
@@ -82,7 +74,31 @@ function mapState(status: string): TorrentInfo['state'] {
return 'unknown'
}
// ─── Auth ──────────────────────────────────────────────────────────────────────
// ─── Authentification ──────────────────────────────────────────────────────────
const AUTH_ERRORS: Record<number, string> = {
400: 'nom d\'utilisateur ou mot de passe incorrect',
401: 'compte désactivé',
402: 'accès refusé',
403: 'code de validation en deux étapes requis',
404: 'code de validation en deux étapes refusé',
}
const TASK_ERRORS: Record<number, string> = {
105: 'accès refusé',
106: 'session expirée',
400: 'envoi du fichier refusé',
401: 'nombre maximal de tâches atteint',
402: 'accès au dossier de destination refusé',
403: 'dossier de destination introuvable',
406: 'aucun dossier de destination par défaut',
408: 'fichier introuvable',
}
function dsErrorText(error: any, table: Record<number, string>): string {
const code = error?.code
return table[code] ? `${table[code]} (code ${code})` : `code ${code ?? 'inconnu'}`
}
async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({
@@ -95,9 +111,9 @@ async function dsLogin(config: ClientConfig): Promise<string> {
format : 'sid',
})
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`Synology a répondu HTTP ${res.status} : vérifiez l'URL de DSM`)
const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
if (!data.success) throw new Error(`Connexion refusée par Synology : ${dsErrorText(data.error, AUTH_ERRORS)}`)
return data.data.sid
}
@@ -111,13 +127,13 @@ async function dsRequest(
): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
if (!data.success) throw new Error(`Erreur Download Station : ${dsErrorText(data.error, TASK_ERRORS)}`)
return data.data
}
// ─── Driver ────────────────────────────────────────────────────────────────────
// ─── Client ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = {
definition: {
@@ -125,13 +141,13 @@ const DS: TorrentClientDriver = {
label : 'Synology Download Station',
filterByManagedHashes: true,
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 },
{ key: 'url', label: 'URL de DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Nom d\'utilisateur', 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 de téléchargement', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -142,8 +158,8 @@ const DS: TorrentClientDriver = {
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}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('synology-ds', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -175,8 +191,7 @@ const DS: TorrentClientDriver = {
const tasks: any[] = data?.tasks ?? []
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
// Pas de catégories chez Synology : filtre sur le dossier de téléchargement s'il est renseigné
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const filtered = normalizedSavePath
? tasks.filter(t => {
@@ -198,7 +213,6 @@ const DS: TorrentClientDriver = {
String(t.id ?? ''),
)
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
const synoIdStr = String(t.id ?? '').toLowerCase()
if (!hash.startsWith('dbid_') && synoIdStr) {
_hashToSynoId.set(hash, synoIdStr)
@@ -238,17 +252,16 @@ const DS: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null)
logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
logger.warn('synology-ds', 'Download Station ne permet pas de choisir un fichier : torrent complet téléchargé')
// Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
if (options?.infohash) {
storeUriHash(url, options.infohash)
logger.debug('synology-ds', `URI→hash mémorisé : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
logger.debug('synology-ds', `Hash mémorisé pour l'URI : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
}
const sid = await dsLogin(config)
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
// IDs existants avant l'ajout, pour repérer la nouvelle tâche
const beforeIds = new Set<string>()
try {
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
@@ -265,22 +278,20 @@ const DS: TorrentClientDriver = {
})
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
return res.json()
}
let data = await doAdd(true)
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
// Code 403 : dossier introuvable sur le NAS, nouvel essai sans destination
if (!data.success && data.error?.code === 403 && config.savePath) {
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
logger.warn('synology-ds', `Dossier « ${config.savePath} » introuvable sur le NAS (code 403) : ajout dans le dossier par défaut`)
data = await doAdd(false)
}
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
if (!data.success) throw new Error(`Download Station a refusé le torrent : ${dsErrorText(data.error, TASK_ERRORS)}`)
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
if (options?.infohash) {
try {
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
@@ -288,12 +299,12 @@ const DS: TorrentClientDriver = {
if (newTask) {
const synoId = String(newTask.id).toLowerCase()
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
logger.debug('synology-ds', `ID Synology ${synoId} associé à ${options.infohash.slice(0, 8)}…`)
}
} catch {}
}
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
logger.info('synology-ds', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async getFiles(config, hash) {
@@ -358,7 +369,7 @@ const DS: TorrentClientDriver = {
_sid : sid,
})
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
+21 -30
View File
@@ -1,15 +1,10 @@
/**
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } 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
// 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
@@ -38,17 +33,17 @@ async function trRequest(
body : JSON.stringify({ method, arguments: args }),
})
// Transmission renvoie 409 avec le session ID à la première requête
// Première requête : 409 avec l'identifiant de session
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')
if (!newSessionId) throw new Error('Identifiant de session absent de la réponse de Transmission (HTTP 409)')
return trRequest(config, method, args, newSessionId)
}
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
if (!res.ok) throw new Error(`Transmission a répondu HTTP ${res.status}`)
const data = await res.json()
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
return { ...data, sessionId }
}
@@ -59,10 +54,6 @@ async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
/**
* Attend que les métadonnées soient disponibles (polling 500 ms),
* puis applique les priorités : fichier cible voulu, tous les autres ignorés.
*/
async function trApplyFilePriority(
config : ClientConfig,
hash : string,
@@ -84,11 +75,11 @@ async function trApplyFilePriority(
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const TR: TorrentClientDriver = {
@@ -97,12 +88,12 @@ const TR: TorrentClientDriver = {
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: 'username', label: 'Nom d\'utilisateur', 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 },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -113,8 +104,8 @@ const TR: TorrentClientDriver = {
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}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -123,10 +114,10 @@ const TR: TorrentClientDriver = {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
logger.debug('transmission', `Healthcheck OK — version ${version}`)
logger.debug('transmission', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -136,7 +127,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
'files', // bytesCompleted et length par fichier (fileStats n'a pas length)
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
@@ -184,21 +175,21 @@ const TR: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
} else {
logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('transmission', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('transmission', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
// Transmission identifie les torrents par ID numérique — on cherche via la liste
// Torrents identifiés par ID numérique : recherche dans la liste
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents: any[] = data.arguments?.torrents ?? []
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
+27 -43
View File
@@ -1,25 +1,19 @@
/**
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
// Drapeaux d'état : 1 démarré, 2 vérification, 4 démarrage après vérification, 8 vérifié, 16 erreur, 32 pause, 64 file d'attente, 128 chargé
function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error'
if (status & 32) return 'paused'
if (status & 2) return 'checking'
// Démarré + chargé + vérifié → on regarde le ratio de progression
// Démarré et vérifié : en cours (le seeding est déduit de la progression)
if (status & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued
if (status & 64) return 'downloading'
return 'unknown'
}
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> {
@@ -31,10 +25,7 @@ async function utTorrentExists(config: ClientConfig, hash: string): Promise<bool
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
/** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
async function utApplyFilePriority(
config : ClientConfig,
hash : string,
@@ -43,7 +34,6 @@ async function utApplyFilePriority(
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
@@ -52,24 +42,22 @@ async function utApplyFilePriority(
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
logger.info('utorrent', `Fichier n° ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
throw new Error(`Délai dépassé : fichiers du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
async function utGetSession(config: ClientConfig): Promise<UTSession> {
@@ -79,11 +67,12 @@ async function utGetSession(config: ClientConfig): Promise<UTSession> {
}
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
if (res.status === 401) throw new Error('uTorrent a refusé la connexion : vérifiez le nom d\'utilisateur et le mot de passe')
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status} : vérifiez l'URL de la WebUI`)
const text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
if (!match) throw new Error('Réponse inattendue de uTorrent : vérifiez l\'URL de la WebUI')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
@@ -104,15 +93,12 @@ async function utRequest(
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status}`)
return res.json()
}
function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent :
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
// Tableau par torrent : [hash, status, name, size, progress, downloaded, uploaded, ratio, ul_speed, dl_speed, eta, label, …]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
@@ -122,7 +108,7 @@ function mapTorrent(t: any[]): TorrentInfo {
size,
downloaded,
uploaded : uploaded ?? 0,
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // ratio × 1000
speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0,
eta : eta ?? -1,
@@ -136,13 +122,13 @@ const UT: TorrentClientDriver = {
id : 'utorrent',
label : 'uTorrent',
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: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Nom d\'utilisateur', 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 },
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -153,8 +139,8 @@ const UT: TorrentClientDriver = {
logger.info('utorrent', `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('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('utorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -164,10 +150,10 @@ const UT: TorrentClientDriver = {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue'
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
logger.debug('utorrent', `Client en ligne (build ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('utorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -185,7 +171,6 @@ const UT: TorrentClientDriver = {
.map(t => {
const info = mapTorrent(t)
// Récupérer le save_path depuis les properties si disponible
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? ''
@@ -201,9 +186,9 @@ const UT: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
@@ -219,18 +204,17 @@ const UT: TorrentClientDriver = {
method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (!res.ok) throw new Error(`Échec de l'ajout (HTTP ${res.status})`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('utorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('utorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
}
// Appliquer le label/catégorie si défini
if (config.category) {
setTimeout(async () => {
try {
+13 -18
View File
@@ -1,7 +1,3 @@
/**
* Gestion des utilisateurs multi-user
* Migration automatique depuis l'ancien auth.json mono-user
*/
import fs from 'fs'
import path from 'path'
@@ -27,11 +23,11 @@ const USERS_PATH = path.join(DATA_DIR, 'users.json')
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
// ── Migration mono → multi-user ───────────────────────────────
// ── Migration depuis auth.json ────────────────────────────────
export function migrateIfNeeded(): void {
if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user
if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré
if (fs.existsSync(USERS_PATH)) return
if (!fs.existsSync(LEGACY_PATH)) return
try {
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
@@ -47,13 +43,13 @@ export function migrateIfNeeded(): void {
createdAt : new Date().toISOString(),
}
writeUsers([admin])
logger.info('users', `Migration auth.json → users.json réussie pour "${legacy.username}"`)
logger.info('users', `Migration de auth.json vers users.json réussie pour « ${legacy.username} »`)
} catch (err) {
logger.error('users', `Échec migration auth.json : ${err instanceof Error ? err.message : err}`)
logger.error('users', `Échec de la migration de auth.json : ${err instanceof Error ? err.message : err}`)
}
}
// ── Helpers fichier ───────────────────────────────────────────
// ── Fichier des utilisateurs ──────────────────────────────────
export function readUsers(): User[] {
try {
@@ -74,18 +70,17 @@ export function findById(id: string): User | undefined { return read
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
/** Sérialise sans passwordHash pour les réponses API */
export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user
return safe
}
// ── CRUD ──────────────────────────────────────────────────────
// ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${username}" déjà utilisé`)
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
}
const user: User = {
id : crypto.randomUUID(),
@@ -97,7 +92,7 @@ export function createUser(username: string, password: string, role: UserRole =
tourSeenAt : null,
}
writeUsers([...users, user])
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`)
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user
}
@@ -113,7 +108,7 @@ export function updateUser(
if (changes.username !== undefined && changes.username !== user.username) {
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${changes.username}" déjà utilisé`)
throw new Error(`Nom d'utilisateur « ${changes.username} » déjà utilisé`)
}
user.username = changes.username
}
@@ -121,7 +116,7 @@ export function updateUser(
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Utilisateur "${user.username}" mis à jour`)
logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
return user
}
@@ -130,7 +125,7 @@ export function deleteUser(id: string): void {
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
writeUsers(users.filter(u => u.id !== id))
logger.info('users', `Utilisateur "${user.username}" supprimé`)
logger.info('users', `Utilisateur « ${user.username} » supprimé`)
}
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
@@ -142,7 +137,7 @@ export function changePassword(id: string, currentPassword: string, newPassword:
}
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Mot de passe changé pour "${user.username}"`)
logger.info('users', `Mot de passe changé pour « ${user.username} »`)
}
export function markLogin(id: string): string | null {