refacto: réécriture des textes, messages et commentaires
This commit is contained in:
1 parent
6a3cb21adf
commit
586eccbbb3
118 files changed
+1621
-2159
No files matched your search
+21
-36
@@ -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
@@ -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,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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,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
@@ -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,4 +1,3 @@
|
||||
// Cadence de l'import automatique
|
||||
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
export const autoImportSchedule = {
|
||||
|
||||
@@ -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
@@ -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 [] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)' : ''}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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é`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user