diff --git a/package.json b/package.json index 6fc0c7c..da2168d 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,20 @@ }, "dependencies": { "@tailwindcss/vite": "^4.2.1", + "@vueuse/core": "^14.2.1", + "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "lucide-vue-next": "^1.0.0", "node-fetch": "^3.3.2", "pinia": "^3.0.4", "tailwindcss": "^4.2.1", "vue": "^3.5.25", - "vue-router": "^5.0.3", - "bcryptjs": "^3.0.3" + "vue-router": "^5.0.3" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.9", @@ -38,7 +41,6 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "vite": "^7.3.1", - "vue-tsc": "^3.1.5", - "@types/bcrypt": "^5.0.2" + "vue-tsc": "^3.1.5" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12e6f0d..3fa284b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tailwindcss/vite': specifier: ^4.2.1 version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vueuse/core': + specifier: ^14.2.1 + version: 14.2.1(vue@3.5.29(typescript@5.9.3)) bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -23,6 +26,9 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + lucide-vue-next: + specifier: ^1.0.0 + version: 1.0.0(vue@3.5.29(typescript@5.9.3)) node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -536,6 +542,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@vitejs/plugin-vue@6.0.4': resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -622,6 +631,19 @@ packages: vue: optional: true + '@vueuse/core@14.2.1': + resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.2.1': + resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + + '@vueuse/shared@14.2.1': + resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} + peerDependencies: + vue: ^3.5.0 + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1039,6 +1061,11 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lucide-vue-next@1.0.0: + resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==} + peerDependencies: + vue: '>=3.0.1' + magic-string-ast@1.0.3: resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} engines: {node: '>=20.19.0'} @@ -1758,6 +1785,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 24.12.0 + '@types/web-bluetooth@0.0.21': {} + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -1886,6 +1915,19 @@ snapshots: typescript: 5.9.3 vue: 3.5.29(typescript@5.9.3) + '@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.2.1 + '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3)) + vue: 3.5.29(typescript@5.9.3) + + '@vueuse/metadata@14.2.1': {} + + '@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + vue: 3.5.29(typescript@5.9.3) + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -2303,6 +2345,10 @@ snapshots: lodash.once@4.1.1: {} + lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)): + dependencies: + vue: 3.5.29(typescript@5.9.3) + magic-string-ast@1.0.3: dependencies: magic-string: 0.30.21 diff --git a/server/auth.ts b/server/auth.ts index ce61260..330e2e8 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -5,12 +5,13 @@ import jwt from 'jsonwebtoken' import type { Request, Response, NextFunction } from 'express' import { JWT_SECRET } from './secret.js' import { DATA_DIR } from './config.js' +import { logger } from './logger.js' -const DATA_PATH = path.join(DATA_DIR, 'auth.json') +const DATA_PATH = path.join(DATA_DIR, 'auth.json') const SALT_ROUNDS = 10 interface AuthData { - username: string + username : string passwordHash: string } @@ -32,25 +33,25 @@ function writeAuth(data: AuthData): void { // GET /api/auth/status export function authStatus(req: Request, res: Response): void { const existing = readAuth() - const token = req.cookies?.fankarr_token + const token = req.cookies?.fankarr_token let loggedIn = false if (token) { try { jwt.verify(token, JWT_SECRET) loggedIn = true - } catch {} + } catch { + logger.debug('auth', 'Token invalide ou expiré') + } } - res.json({ - setup: !!existing, - loggedIn - }) + res.json({ setup: !!existing, loggedIn }) } // POST /api/auth/setup export function authSetup(req: Request, res: Response): void { if (readAuth()) { + logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà') res.status(400).json({ error: 'Un compte existe déjà' }) return } @@ -63,6 +64,7 @@ export function authSetup(req: Request, res: Response): void { const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS) writeAuth({ username, passwordHash }) + logger.info('auth', `Compte créé pour "${username}"`) const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) @@ -84,17 +86,26 @@ export function authLogin(req: Request, res: Response): void { } if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) { + logger.warn('auth', `Échec de connexion pour "${username}"`) res.status(401).json({ error: 'Identifiants incorrects' }) return } const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) + logger.info('auth', `Connexion réussie pour "${username}"`) res.json({ success: true }) } // POST /api/auth/logout -export function authLogout(_req: Request, res: Response): void { +export function authLogout(req: Request, res: Response): void { + const token = req.cookies?.fankarr_token + if (token) { + try { + const payload = jwt.verify(token, JWT_SECRET) as any + logger.info('auth', `Déconnexion de "${payload.username}"`) + } catch {} + } res.clearCookie('fankarr_token') res.json({ success: true }) } @@ -103,6 +114,7 @@ export function authLogout(_req: Request, res: Response): void { 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 } @@ -110,6 +122,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo jwt.verify(token, JWT_SECRET) next() } catch { + logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`) res.status(401).json({ error: 'Token invalide' }) } } \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index eb05336..b86ea55 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,11 +12,11 @@ import { import qbittorrentDriver from './torrent-clients/qbittorrent.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' -import { DATA_DIR, BASE_DIR } from './config.js' +import { DATA_DIR, BASE_DIR } from './config.js' registerDriver(qbittorrentDriver) -const app = express() +const app = express() const PORT = Number(process.env.PORT) || 9898 const FANKAI_API = 'https://metadata.fankai.fr' const GITHUB_BASE = process.env.GITHUB_BASE @@ -25,7 +25,6 @@ const GITHUB_BASE = process.env.GITHUB_BASE app.use(express.json()) app.use(cookieParser()) -// ── Static frontend (prod) ───────────────────────────────────── const PUBLIC_PATH = path.join(BASE_DIR, 'public') if (fs.existsSync(PUBLIC_PATH)) { app.use(express.static(PUBLIC_PATH)) @@ -43,7 +42,9 @@ app.get('/api/settings', requireAuth, (_req, res) => { }) app.post('/api/settings', requireAuth, (req, res) => { const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles } = req.body - res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles })) + const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles }) + logger.info('api', 'Paramètres mis à jour') + res.json(updated) }) // ── Torrent clients ──────────────────────────────────────────── @@ -56,13 +57,13 @@ app.get('/api/torrent-clients', requireAuth, (_req, res) => { app.post('/api/torrent-clients', requireAuth, (req, res) => { const { name, type, config } = req.body if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return } - if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } + if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return } res.json(sanitizeClient(addClient(name, type, config))) }) app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => { const ok = removeClient(String(req.params.uuid)) - if (!ok) res.status(404).json({ error: 'Client introuvable' }) - else res.json({ ok: true }) + if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return } + res.json({ ok: true }) }) app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => { const { type, config } = req.body @@ -87,8 +88,7 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) }) // ── Cache GitHub ─────────────────────────────────────────────── - -const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures +const CACHE_TTL_MS = 6 * 60 * 60 * 1000 interface CacheEntry { data: T; expiresAt: number } const _cache = new Map>() @@ -107,122 +107,59 @@ function cacheSet(key: string, data: T): void { function cacheClear(): void { _cache.clear() } async function githubGet(urlPath: string): Promise { - const key = urlPath - const cached = cacheGet(key) + const cached = cacheGet(urlPath) if (cached) return cached const res = await fetch(`${GITHUB_BASE}/${urlPath}`) if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`) const data = await res.json() - cacheSet(key, data) + cacheSet(urlPath, data) return data } async function readAvailable(): Promise { - try { - return await githubGet('available.json') as number[] - } catch { return [] } + try { return await githubGet('available.json') as number[] } + catch { return [] } } async function readSerieData(serieId: number): Promise { - try { - return await githubGet(`series/${serieId}.json`) - } catch { return null } + try { return await githubGet(`series/${serieId}.json`) } + catch { return null } } -// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique -// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... } function extractTorrentsFromSerieData(sd: any): any[] { const result: any[] = [] if (!sd) return result - - // Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison) for (const t of sd.torrents ?? []) { - result.push({ - ...t, - raw : t.title, - type : 'pack_integrale', - serie_id : sd.id, - serie_title: sd.title, - // episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i] - _serieLevel: true, - _serieData : sd, - _torrentIdx: (sd.torrents ?? []).indexOf(t), - }) + result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) }) } - - // Torrents au niveau saison → pack_saison for (const season of sd.seasons ?? []) { for (const t of season.torrents ?? []) { - result.push({ - ...t, - raw : t.title, - type : 'pack_saison', - serie_id : sd.id, - serie_title : sd.title, - season_id : season.id, - season_number: season.season_number, - _seasonLevel : true, - _season : season, - _torrentIdx : (season.torrents ?? []).indexOf(t), - }) + result.push({ ...t, raw: t.title, type: 'pack_saison', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, _seasonLevel: true, _season: season, _torrentIdx: (season.torrents ?? []).indexOf(t) }) } - - // Torrents au niveau épisode → episode for (const ep of season.episodes ?? []) { for (const t of ep.torrents ?? []) { - result.push({ - ...t, - raw : t.title, - type : 'episode', - serie_id : sd.id, - serie_title : sd.title, - season_id : season.id, - season_number: season.season_number, - episode_id : ep.id, - episode_number: ep.episode_number, - _episodeLevel: true, - _episode : ep, - _torrentIdx : (ep.torrents ?? []).indexOf(t), - }) + result.push({ ...t, raw: t.title, type: 'episode', serie_id: sd.id, serie_title: sd.title, season_id: season.id, season_number: season.season_number, episode_id: ep.id, episode_number: ep.episode_number, _episodeLevel: true, _episode: ep, _torrentIdx: (ep.torrents ?? []).indexOf(t) }) } } } - return result } -// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx] -// Construit les épisodes résolus pour un torrent donné par son infohash function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] { const resolved: any[] = [] const h = hash.toLowerCase() - for (const season of sd.seasons ?? []) { if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue for (const ep of season.episodes ?? []) { const paths: any[] = ep.paths ?? [] - - // Nouveau format { infohash, path } ou legacy string let filePath: string | null = null for (const p of paths) { - if (typeof p === 'string') { - if (!filePath) filePath = p.replace(/\\/g, '/') // legacy fallback - } else if (p?.infohash?.toLowerCase() === h) { - filePath = p.path.replace(/\\/g, '/') - break - } + if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') } + else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break } } if (!filePath) continue - const filename = filePath.split('/').pop() ?? filePath - resolved.push({ - episode_id : ep.id, - episode_number : ep.episode_number, - season_number : season.season_number, - season_id : season.id, - filename, - original_filename: filename, - }) + resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename }) } } return resolved @@ -234,15 +171,6 @@ async function fankaiGet(endpoint: string): Promise { return res.json() } -function normalizeTitle(s: string): string { - return s.toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]/g, ' ') - .replace(/\s+/g, ' ') - .trim() -} - function imgProxy(url: string | null | undefined, w: number, q = 70): string | null { if (!url) return null const stripped = url.replace(/^https?:\/\//, '') @@ -250,196 +178,92 @@ function imgProxy(url: string | null | undefined, w: number, q = 70): string | n } function normalizeSerie(serie: any): any { - const poster = serie.poster_image ?? serie.images?.poster ?? null - const fanart = serie.fanart_image ?? serie.images?.fanart ?? null - return { - ...serie, - poster_image: imgProxy(poster, 300), - fanart_image: imgProxy(fanart, 1280, 60), - } + return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) } } - function normalizeSeason(season: any): any { - const poster = season.poster_image ?? season.images?.poster ?? null - return { - ...season, - poster_image: imgProxy(poster, 300), - } + return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) } } - function normalizeEpisode(ep: any): any { - const thumb = ep.thumb_image ?? ep.thumbnail ?? null - return { - ...ep, - thumb_image: imgProxy(thumb, 400), - } + return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) } } -// ── Enrichissement seriesData avec original_filename depuis l'API ────────────── -// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename -// dans chaque épisode du serieData, indexé par episode_id async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise { - const results: any[] = [] - - for (const sd of seriesData) { - if (!sd) { results.push(sd); continue } - - // Fetcher title_for_plex depuis l'API série - let title_for_plex: string | null = null - try { - const serieApi = await fankaiGet(`/series/${sd.id}`) - title_for_plex = serieApi.title_for_plex ?? null - } catch {} - - const enrichedSeasons: any[] = [] - for (const season of sd.seasons ?? []) { - let epsData: any[] = [] - try { - // 2 tentatives en cas d'erreur réseau ponctuelle - let lastErr: any - for (let attempt = 0; attempt < 2; attempt++) { - try { - const res = await fankaiGet(`/seasons/${season.id}/episodes`) - epsData = Array.isArray(res) ? res : (res.episodes ?? []) - break - } catch (e) { - lastErr = e - if (attempt === 0) await new Promise(r => setTimeout(r, 500)) + return Promise.all(seriesData.map(async (sd) => { + if (!sd) return sd + const [serieApi, enrichedSeasons] = await Promise.all([ + fankaiGet(`/series/${sd.id}`).catch(() => null), + Promise.all((sd.seasons ?? []).map(async (season: any) => { + let epsData: any[] = [] + try { + let lastErr: any + for (let attempt = 0; attempt < 2; attempt++) { + try { const res = await fankaiGet(`/seasons/${season.id}/episodes`); epsData = Array.isArray(res) ? res : (res.episodes ?? []); break } + catch (e) { lastErr = e; if (attempt === 0) await new Promise(r => setTimeout(r, 500)) } } + if (epsData.length === 0 && lastErr) throw lastErr + } catch { return season } + const origMap = new Map() + for (const ep of epsData) { + if (ep.id) origMap.set(ep.id, { original_filename: ep.original_filename ?? '', formatted_name: ep.formatted_name ?? '' }) } - if (epsData.length === 0 && lastErr) throw lastErr - } catch { - enrichedSeasons.push(season) - continue - } - - // Petit délai pour ne pas saturer l'API - await new Promise(r => setTimeout(r, 50)) - - const origMap = new Map() - for (const ep of epsData) { - if (ep.id) origMap.set(ep.id, { - original_filename: ep.original_filename ?? '', - formatted_name : ep.formatted_name ?? '', - }) - } - - const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({ - ...ep, - original_filename: origMap.get(ep.id)?.original_filename ?? null, - formatted_name : origMap.get(ep.id)?.formatted_name ?? null, + return { ...season, episodes: (season.episodes ?? []).map((ep: any) => ({ ...ep, original_filename: origMap.get(ep.id)?.original_filename ?? null, formatted_name: origMap.get(ep.id)?.formatted_name ?? null })) } })) - - enrichedSeasons.push({ ...season, episodes: enrichedEpisodes }) - } - - results.push({ ...sd, title_for_plex, seasons: enrichedSeasons }) - } - - return results + ]) + return { ...sd, title_for_plex: serieApi?.title_for_plex ?? null, seasons: enrichedSeasons } + })) } -// Helper réutilisable : charge + enrichit toutes les seriesData du cache async function loadEnrichedSeriesData(): Promise { const ids = await readAvailable() const allSd = await Promise.all(ids.map(id => readSerieData(id))) - const valid = allSd.filter(Boolean) - return enrichSeriesDataWithOriginalFilenames(valid) + return enrichSeriesDataWithOriginalFilenames(allSd.filter(Boolean)) } -function computeSerieDownloadState( - serieData : any | null, - organized : Record>, - activeTorrents: Set -): 'none' | 'downloading' | 'partial' | 'complete' { +function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' { if (!serieData) return 'none' - const allTorrents = extractTorrentsFromSerieData(serieData) if (allTorrents.length === 0) return 'none' - - const hasActive = allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase())) - if (hasActive) return 'downloading' - - // Collecter tous les episode_id de la série et ceux organisés + if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading' const allEpisodeIds = new Set() const organizedEpisodeIds = new Set() - for (const season of serieData.seasons ?? []) { for (const ep of season.episodes ?? []) { - if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) { - allEpisodeIds.add(ep.id) - } + if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) allEpisodeIds.add(ep.id) } } - - // Vérifier via organized.json : si le filename du path est organisé for (const t of allTorrents) { const hash = t.infohash?.toLowerCase() if (!hash) continue const orgFiles = organized[hash] ?? {} if (Object.keys(orgFiles).length === 0) continue - const resolved = buildResolvedEpisodes(serieData, hash, t.season_number) - for (const ep of resolved) { + for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } } - - if (allEpisodeIds.size === 0) return 'none' - if (organizedEpisodeIds.size === 0) return 'none' + if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none' if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete' return 'partial' } +// ── Routes série ─────────────────────────────────────────────── app.get('/api/series', requireAuth, async (_req, res) => { try { - const [apiData, availableIds] = await Promise.all([ - fankaiGet('/series'), - readAvailable(), - ]) + const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()]) const availableSet = new Set(availableIds) - - // Charger organized.json let organized: Record> = {} - try { - const orgPath = path.join(DATA_DIR, 'organized.json') - if (fs.existsSync(orgPath)) - organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) - } catch {} - - // Récupérer les hashes actifs depuis qB (best effort) + try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const activeTorrents = new Set() - try { - const { category } = readSettings() - const active = await dispatchList(category ?? 'fankai') - for (const t of active) { - if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) - } - } catch {} - + try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {} const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie) - - // Charger les series data en parallèle uniquement pour celles qui ont des torrents const serieDataMap = new Map() - await Promise.all( - seriesRaw - .filter((s: any) => availableSet.has(s.id)) - .map(async (s: any) => { - const sd = await readSerieData(s.id) - if (sd) serieDataMap.set(s.id, sd) - }) - ) - + await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) })) res.json({ series: seriesRaw.map((serie: any) => { const hasTorrents = availableSet.has(serie.id) const serieData = serieDataMap.get(serie.id) ?? null - return { - ...serie, - torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0, - has_torrents : hasTorrents, - download_state: computeSerieDownloadState(serieData, organized, activeTorrents), - } + return { ...serie, torrent_count: serieData ? extractTorrentsFromSerieData(serieData).length : 0, has_torrents: hasTorrents, download_state: computeSerieDownloadState(serieData, organized, activeTorrents) } })}) } 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' }) } }) @@ -447,156 +271,76 @@ app.get('/api/series', requireAuth, async (_req, res) => { app.get('/api/series/:id', requireAuth, async (req, res) => { const id = Number(req.params.id) try { - const [serieRaw, seasonsData, serieData] = await Promise.all([ - fankaiGet(`/series/${id}`), - fankaiGet(`/series/${id}/seasons`), - readSerieData(id), - ]) + const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)]) const serie = normalizeSerie(serieRaw) const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? []) - - // Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.) const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => { const epsData = await fankaiGet(`/seasons/${season.id}/episodes`) const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode) return { ...normalizeSeason(season), episodes } })) - - // Charger organized.json let organized: Record> = {} - try { - const orgPath = path.join(DATA_DIR, 'organized.json') - if (fs.existsSync(orgPath)) - organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) - } catch {} - - // ── Construire les maps torrent depuis series/{id}.json ─────────── - const availableEpisodeIds = new Set() - const episodeTorrentMap : Record = {} // episode_id → ref - const seasonTorrentMapBySn : Record = {} // season_number → ref - const integraleTorrents : any[] = [] - const packEpisodesTorrents : any[] = [] - const organizedEpisodeIds = new Set() - + try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} + const availableEpisodeIds = new Set() + const episodeTorrentMap : Record = {} + const seasonTorrentMapBySn : Record = {} + const integraleTorrents : any[] = [] + const packEpisodesTorrents : any[] = [] + const organizedEpisodeIds = new Set() if (serieData) { - // ── Torrents niveau série (pack_integrale) ────────────────── for (const t of (serieData.torrents ?? [])) { - const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false } integraleTorrents.push({ ...t, raw: t.title }) - const resolved = buildResolvedEpisodes(serieData, t.infohash) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) - const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - for (const ep of resolved) { - if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) - } + for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } } - - // ── Torrents niveau saison (pack_saison) ──────────────────── for (const season of serieData.seasons ?? []) { for (const t of (season.torrents ?? [])) { - const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } - seasonTorrentMapBySn[season.season_number] = ref - + seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) - const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - for (const ep of resolved) { - if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) - } + for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) } } - - // ── Torrents niveau épisode ───────────────────────────── for (const ep of season.episodes ?? []) { for (const t of (ep.torrents ?? [])) { - const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true } - episodeTorrentMap[ep.id] = ref + episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true } availableEpisodeIds.add(ep.id) - const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} - const paths: any[] = ep.paths ?? [] - const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) + const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase()) const filename = match ? match.path.split('/').pop() : null if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) } } } - - // Pack_saison multi-saisons → packEpisodesTorrents si couvre plusieurs saisons - // (cas où un seul torrent couvre plusieurs saisons mais pas toutes → affiché en hero) - // Pour l'instant on le traite comme pack_integrale si au niveau série } - - // ── Promotion intégrale ─────────────────────────────────────────── - // Si pas de pack_integrale explicite mais un pack_saison couvre toutes les saisons - // → déjà géré car on met les torrents série en integraleTorrents directement - - // ── Enrichissement des saisons API ─────────────────────────────── const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const eps = season.episodes.map((ep: any) => { const epTorrent = episodeTorrentMap[ep.id] ?? null - - // Si pas de torrent épisode, chercher le fankai du torrent qui couvre cet épisode via paths let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null if (fankai === null && serieData) { - // Chercher dans les torrents série/saison quel torrent a un path pour cet épisode outer: for (const sd_season of serieData.seasons ?? []) { for (const sdEp of sd_season.episodes ?? []) { if (sdEp.id !== ep.id) continue for (const p of sdEp.paths ?? []) { const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null if (!ih) continue - // Chercher dans les torrents série - for (const t of serieData.torrents ?? []) { - if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } - } - // Chercher dans les torrents saison - for (const s of serieData.seasons ?? []) { - for (const t of s.torrents ?? []) { - if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } - } - } + for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } + for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } } } } } } - - return { - ...ep, - available : availableEpisodeIds.has(ep.id), - torrent : epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, - fankai : fankai, - organized : organizedEpisodeIds.has(ep.id), - } + return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) } }) const total = eps.filter((e: any) => e.available).length const orgCount = eps.filter((e: any) => e.organized).length - const seasonOrganizedState = - orgCount === 0 ? 'none' : - orgCount >= total && total > 0 ? 'complete' : 'partial' - return { - ...season, - torrent : seasonTorrentMapBySn[season.season_number] ?? null, - organized_state : seasonOrganizedState, - organized_count : orgCount, - episodes : eps, - } + return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps } }) - - // ── Hero bundles ────────────────────────────────────────────────── - function packEpisodesLabel(t: any): string { - return t.raw ?? t.title ?? 'Pack' - } - - const heroBundles: any[] = [ - ...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })), - ...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) - ] - - res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles }) + res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) }) } 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' }) } }) @@ -605,67 +349,46 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { app.post('/api/download', requireAuth, async (req, res) => { const url = req.body.magnet ?? req.body.torrent_url if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } + logger.info('api', 'Téléchargement demandé') const results = await dispatchDownload(url) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) }) -// ── Downloads — liste torrents Fankai ────────────────────────── +// ── Downloads list ───────────────────────────────────────────── app.get('/api/downloads', requireAuth, async (_req, res) => { try { const { category } = readSettings() const torrents = await dispatchList(category ?? 'fankai') - let organized: Record> = {} - try { - const orgPath = path.join(DATA_DIR, 'organized.json') - if (fs.existsSync(orgPath)) - organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) - } catch {} - + try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {} const enriched = await Promise.all(torrents.map(async (t: any) => { - const orgFiles = organized[t.hash] ?? {} - // Chercher les métadonnées via le cache GitHub + const orgFiles = organized[t.hash] ?? {} let totalFiles = 1 try { const availableIds = await readAvailable() for (const id of availableIds) { const sd = await readSerieData(id) if (!sd) continue - const allT = extractTorrentsFromSerieData(sd) - const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) - if (match) { - const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number) - totalFiles = resolved.length || 1 - break - } + const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) + if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break } } } catch {} - const doneFiles = Object.keys(orgFiles).length - let organizeState: 'none' | 'partial' | 'done' = 'none' - if (doneFiles >= totalFiles) organizeState = 'done' - else if (doneFiles > 0) organizeState = 'partial' - + const doneFiles = Object.keys(orgFiles).length + const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none' const notif = recentOrganized.find(n => n.hash === t.hash) - const errorFiles = notif?.errorFiles ?? [] - - return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles } + return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] } })) - 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' }) } }) -// ── Résultats d'organisation récents ────────────────────────── +// ── Organize ─────────────────────────────────────────────────── interface OrganizeNotif { - hash : string - name : string - done : number - skipped : number - errors : number - errorFiles : { file: string; error: string }[] - at : string + hash: string; name: string; done: number; skipped: number; errors: number + errorFiles: { file: string; error: string }[]; at: string } const recentOrganized: OrganizeNotif[] = [] const MAX_NOTIFS = 20 @@ -678,128 +401,108 @@ function pushNotif(n: OrganizeNotif) { app.get('/api/organize/recent', requireAuth, (_req, res) => { res.json(recentOrganized) }) - app.post('/api/organize/recent/clear', requireAuth, (_req, res) => { recentOrganized.length = 0 + logger.info('api', 'Historique des imports effacé') res.json({ ok: true }) }) - app.post('/api/organize', requireAuth, async (req, res) => { const { hash, name, save_path } = req.body - if (!hash || !name || !save_path) { - res.status(400).json({ error: 'hash, name et save_path requis' }) - return - } + if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return } try { const seriesData = await loadEnrichedSeriesData() const result = await organizeTorrent(hash, name, save_path, seriesData) + if (result.done > 0 || result.errors.length > 0) { + pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() }) + } 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' }) } }) -// ── Logs ────────────────────────────────────────────────────── +// ── Logs ─────────────────────────────────────────────────────── app.get('/api/logs', requireAuth, (req, res) => { - const limit = Number(req.query.limit) || 100 - const level = (req.query.level as string) || 'all' - const source = (req.query.source as string) || undefined - const entries = readLogs({ limit, level: level as any, source }) - res.json({ entries, size: logsFileSize() }) + const limit = Number(req.query.limit) || 100 + const level = (req.query.level as string) || 'all' + const source = (req.query.source as string) || undefined + res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() }) }) - app.post('/api/logs/clear', requireAuth, (_req, res) => { clearLogs() logger.info('api', 'Logs effacés') res.json({ ok: true }) }) -// ── System info ──────────────────────────────────────────────── +// ── Système ──────────────────────────────────────────────────── app.get('/api/system', requireAuth, (_req, res) => { - const isDocker = fs.existsSync('/.dockerenv') - res.json({ isDocker }) + res.json({ isDocker: fs.existsSync('/.dockerenv') }) }) - app.get('/api/browse', requireAuth, (req, res) => { const dirPath = (req.query.path as string) || '/' try { 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)) - const parent = dirPath === '/' ? null : path.dirname(dirPath) - res.json({ path: dirPath, parent, dirs }) + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b)) + res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), 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' }) } }) - app.get('/api/torrents/status', requireAuth, async (_req, res) => { try { const available = await readAvailable() res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 }) } catch { res.json({ exists: false, count: 0, empty: true }) } }) - -// ── Update : vide le cache pour forcer le rechargement depuis GitHub ────────── app.post('/api/update', requireAuth, async (_req, res) => { try { cacheClear() - // Pré-charger available.json pour vérifier que GitHub répond + logger.info('api', 'Cache GitHub vidé — rechargement forcé') const availableIds = await readAvailable() if (!Array.isArray(availableIds)) throw new Error('available.json invalide') + logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) res.json({ ok: true, count: availableIds.length }) } 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' }) } }) - app.post('/api/scan', requireAuth, async (_req, res) => { try { const { mediaPath } = readSettings() - const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') + logger.info('api', 'Scan médiathèque manuel lancé') const seriesData = await loadEnrichedSeriesData() - const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) - logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) + 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' }) } }) -// ── SPA fallback (prod) ──────────────────────────────────────── if (fs.existsSync(PUBLIC_PATH)) { - app.get('*path', (_req, res) => { - res.sendFile(path.join(PUBLIC_PATH, 'index.html')) - }) + app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) }) } -// ── Start ────────────────────────────────────────────────────── +// ── Démarrage ────────────────────────────────────────────────── app.listen(PORT, async () => { - console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) - console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`) + logger.info('api', `Serveur démarré sur le port ${PORT}`) - // Pré-chauffer le cache available.json au démarrage try { const available = await readAvailable() - console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`) + logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`) } catch (err) { - console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`) + logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`) } - // Scan initial du mediaPath const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') - const { mediaPath } = readSettings() - loadEnrichedSeriesData().then(seriesData => - scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) - ).catch(err => - logger.error('organize', `scanMediaPath échoué: ${err}`) - ) + const { mediaPath } = readSettings() + loadEnrichedSeriesData() + .then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)) + .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`)) - // Auto-organise — polling différentiel toutes les 5 minutes - // Le worker ne se lance que si un torrent vient de passer en seeding const autoOrganize = async () => { try { const { category } = readSettings() @@ -814,10 +517,10 @@ app.listen(PORT, async () => { } ) } catch (err) { - console.error('[organize] Erreur auto-organise:', err) + logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`) } } - // Premier run 10s après démarrage, puis toutes les 5 minutes + setTimeout(() => { autoOrganize() setInterval(autoOrganize, 5 * 60_000) diff --git a/server/organize-worker.ts b/server/organize-worker.ts index f6878f0..9bbd2e2 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -2,13 +2,6 @@ * organize-worker.ts * ================== * Worker thread dédié à l'organisation des fichiers. - * Tourne dans un thread séparé pour ne pas bloquer le event loop Express. - * - * Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] } - * Messages envoyés : - * { type: 'log', level: 'info'|'warn'|'error', msg: string } - * { type: 'result', hash: string, name: string, done, skipped, errors } - * { type: 'done' } */ import fs from 'fs' @@ -20,6 +13,7 @@ import { DATA_DIR } from './config.js' const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') // ─── Utils log ──────────────────────────────────────────────── +// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe 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 }) } @@ -53,32 +47,27 @@ function isOrganized(hash: string, filename: string): boolean { return !!loadOrganized()[hash]?.[filename] } -// ─── Lookup torrent dans les seriesData passées via message ─── +// ─── Lookup torrent dans les seriesData ─────────────────────── function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null { for (const sd of seriesData) { - // Niveau série for (const t of sd.torrents ?? []) { if (t.infohash?.toLowerCase() === hash.toLowerCase()) return { torrent: t, serieData: sd } } - // Niveau saison for (const season of sd.seasons ?? []) { for (const t of season.torrents ?? []) { if (t.infohash?.toLowerCase() === hash.toLowerCase()) return { torrent: { ...t, _season: season }, serieData: sd } } - // Niveau épisode — compter combien d'épisodes partagent ce torrent const matchingEpisodes = season.episodes?.filter((ep: any) => ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ) ?? [] if (matchingEpisodes.length === 1) { - // Torrent propre à un seul épisode → cas épisode unique const ep = matchingEpisodes[0] const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd } } else if (matchingEpisodes.length > 1) { - // Même torrent partagé entre plusieurs épisodes → traiter comme pack_saison const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) return { torrent: { ...t, _season: season }, serieData: sd } } @@ -87,9 +76,6 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser return null } -// Construit la map filename → { season_number, original_filename, fullPath } -// Supporte les deux formats : { infohash, path } et string (legacy) -// destFilename : original_filename si nfoSupport, formatted_name sinon function buildFileMap( serieData : any, hash : string, @@ -117,14 +103,12 @@ function buildFileMap( if (!matchedPath) continue const filename = matchedPath.split('/').pop() ?? matchedPath - // nfoSupport → original_filename (correspond aux .nfo GitLab) - // sinon → formatted_name de l'API, ou fallback original_filename const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null if (!nfoSupport && !fmtName) { - warn(`[organize] formatted_name manquant pour ep ${ep.id} (ep ${ep.episode_number}) — fallback original_filename`) + warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`) } const destName = nfoSupport @@ -141,7 +125,7 @@ function buildFileMap( return map } -// ─── Dossiers exclus ───────────────────────────────────────── +// ─── Dossiers exclus ────────────────────────────────────────── const EXCLUDED_FOLDERS = new Set([ 'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork', 'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced', @@ -159,7 +143,7 @@ function isInExcludedFolder(filePath: string[]): boolean { return false } -// ─── Map correction titres API → titres GitLab ─────────────── +// ─── Map titres API → GitLab ────────────────────────────────── const SERIE_TITLE_GITLAB_MAP: Record = { 'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', 'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū', @@ -183,21 +167,21 @@ const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' async function fetchJson(url: string): Promise { const { default: nodeFetch } = await import('node-fetch') const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) - if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } async function fetchBinary(url: string): Promise { const { default: nodeFetch } = await import('node-fetch') const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) - if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) return Buffer.from(await res.arrayBuffer()) } async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise { const gitlabTitle = getGitlabSerieTitle(serieTitle) const folderPath = `pack/${gitlabTitle}` - log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`) + log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`) let files: any[] = [] try { @@ -212,30 +196,32 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi page++ } } catch (err) { - warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`) + warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`) return } - if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return } + if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return } const fileEntries = files.filter((f: any) => f.type === 'blob') - log(`[nfo] ${fileEntries.length} fichiers à télécharger`) + log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`) + let downloaded = 0, skipped = 0, failed = 0 for (const entry of fileEntries) { const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') const destPath = path.join(destRoot, relativePath) - if (fs.existsSync(destPath)) continue + if (fs.existsSync(destPath)) { skipped++; continue } try { fs.mkdirSync(path.dirname(destPath), { recursive: true }) const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` const data = await fetchBinary(rawUrl) fs.writeFileSync(destPath, data) - log(`[nfo] ✓ ${relativePath}`) + downloaded++ } catch (err) { - warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`) + warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`) + failed++ } } - log(`[nfo] GitLab synchronisé pour "${gitlabTitle}"`) + log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`) } // ─── Filesystem ops ─────────────────────────────────────────── @@ -244,18 +230,16 @@ function seasonFolder(n: number): string { return `Saison ${n}` } function tryHardlink(src: string, dest: string): boolean { try { fs.linkSync(src, dest); return true } catch (err) { - warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`) + debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`) return false } } -// ─── Sanitise un nom de dossier ────────────────────────────── -// Remplace les caractères interdits sur les systèmes de fichiers communs function sanitizeDirName(name: string): string { return name - .replace(/:/g, ' -') // "Kaguya-sama : Love" → "Kaguya-sama - Love" - .replace(/[<>"/\\|?*]/g, '') // autres caractères interdits - .replace(/\s+/g, ' ') // espaces multiples + .replace(/:/g, ' -') + .replace(/[<>"/\\|?*]/g, '') + .replace(/\s+/g, ' ') .trim() } @@ -264,12 +248,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings() const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] } - log(`[organize] ── ${name} ──`) - log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`) - const found = findTorrentByHash(hash, seriesData) if (!found) { - error(`[organize] ✗ Hash ${hash} introuvable dans les données série`) + error(`Hash ${hash} introuvable dans les données série`) throw new Error(`Torrent introuvable : ${hash}`) } @@ -281,10 +262,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser ? serieData.title_for_plex : rawTitle ) - const torrentTitle = torrent.title ?? name - log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`) - // ── NFO/images depuis GitLab ────────────────────────────── + debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`) + if (nfoSupport) { await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle)) } @@ -325,7 +305,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } if (!src) { - error(`[organize] ✗ ${filename} introuvable`) + error(`Fichier source introuvable : ${filename}`) return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] } } @@ -339,16 +319,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser } } markOrganized(hash, filename) - log(`[organize] ✓ ${destName} → Saison ${season.season_number}`) + log(`${destName} → Saison ${season.season_number}`) return { ...result, total: 1, done: 1 } } // ── Pack saison / intégrale ─────────────────────────────── const seasonFilter = torrent._season?.season_number - const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter) + const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter) if (fileMap.size === 0) { - warn(`[organize] Aucun fichier mappé pour ${name}`) + warn(`Aucun fichier mappé pour "${name}"`) return result } @@ -357,8 +337,6 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser for (const [filename, { season_number, original_filename, fullPath }] of fileMap) { if (isOrganized(hash, filename)) { result.skipped++; continue } - // Source : on utilise save_path + fullPath (chemin relatif exact dans le torrent) - // save_path vient de qBittorrent → résiste aux renommages de dossier const candidates = [ path.join(savePath, fullPath), path.join(completePath, fullPath), @@ -369,7 +347,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } if (!src) { - error(`[organize] ✗ ${filename} introuvable`) + error(`Fichier source introuvable : ${filename}`) result.errors.push({ file: filename, error: 'Source introuvable' }) continue } @@ -389,51 +367,52 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser } markOrganized(hash, filename) result.done++ - log(`[organize] ✓ ${original_filename} → Saison ${season_number}`) + debug(`${original_filename} → Saison ${season_number}`) } catch (err) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' - error(`[organize] ✗ ${filename}: ${msg}`) + error(`Échec import "${filename}" : ${msg}`) result.errors.push({ file: filename, error: msg }) } } - log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`) return result } -// ─── Boucle principale du worker ───────────────────────────── +// ─── Boucle principale ──────────────────────────────────────── parentPort?.on('message', async (msg: any) => { if (msg.type !== 'run') return const torrents: any[] = msg.torrents const seriesData: any[] = msg.seriesData ?? [] - const organized = loadOrganized() - const { nfoSupport } = readSettings() + const organized = loadOrganized() + const { nfoSupport } = readSettings() for (const t of torrents) { if (t.state !== 'seeding') continue const found = findTorrentByHash(t.hash, seriesData) if (!found) { - warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`) + debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`) continue } - // Vérifier si déjà tout organisé const { torrent, serieData } = found - const seasonFilter = torrent._season?.season_number - const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter) - const allDone = fileMap.size > 0 + const seasonFilter = torrent._season?.season_number + const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter) + const allDone = fileMap.size > 0 ? [...fileMap.keys()].every(f => organized[t.hash]?.[f]) : !!organized[t.hash]?.[t.name] - if (allDone) continue + if (allDone) { + debug(`"${t.name}" déjà entièrement importé — skip`) + continue + } try { const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData) parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result }) } catch (err) { - error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`) + error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`) } } diff --git a/server/organize.ts b/server/organize.ts index 9966553..5e4c55f 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -4,8 +4,8 @@ * Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express. */ -import fs from 'fs' -import path from 'path' +import fs from 'fs' +import path from 'path' import { Worker } from 'worker_threads' import { logger } from './logger.js' import { DATA_DIR } from './config.js' @@ -13,10 +13,7 @@ import { readSettings } from './settings.js' const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') -// Un seul worker à la fois let workerRunning = false - -// État précédent des torrents pour détecter les passages downloading → seeding const _prevStates = new Map() function resolveWorkerPath(): string { @@ -33,10 +30,8 @@ export interface OrganizeResult { errors : { file: string; error: string }[] } -/** - * Scan initial du mediaPath au démarrage. - * Construit l'index filename → infohash depuis les seriesData passées en paramètre. - */ +// ── Scan initial ────────────────────────────────────────────── + export async function scanMediaPath( mediaPath : string, organizedPath: string, @@ -45,20 +40,18 @@ export async function scanMediaPath( const result = { found: 0, added: 0 } if (!fs.existsSync(mediaPath)) { - logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`) + logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`) return result } - // Construire index filename → infohash depuis les seriesData - const filenameIndex = new Map() // filename → infohash + logger.info('organize', `Scan de la médiathèque : ${mediaPath}`) + + const filenameIndex = new Map() for (const sd of seriesData) { for (const season of sd.seasons ?? []) { for (const ep of season.episodes ?? []) { for (const p of ep.paths ?? []) { - if (typeof p === 'string') { - // Legacy : pas d'infohash disponible, on skip pour le scan - continue - } + if (typeof p === 'string') continue const hash = p.infohash?.toLowerCase() if (!hash || !p.path) continue const filename = p.path.replace(/\\/g, '/').split('/').pop() @@ -68,7 +61,6 @@ export async function scanMediaPath( } } - // Charger organized.json existant let organized: Record> = {} try { if (fs.existsSync(organizedPath)) @@ -100,7 +92,6 @@ export async function scanMediaPath( walk(mediaPath) - // Nettoyer les entrées orphelines let removed = 0 for (const [hash, files] of Object.entries(organized)) { for (const filename of Object.keys(files)) { @@ -114,28 +105,29 @@ export async function scanMediaPath( if (result.added > 0 || removed > 0) { fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') - logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`) + logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`) } else { - logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`) + logger.info('organize', `Scan terminé — ${result.found} fichiers, rien de nouveau`) } return result } -/** - * Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub. - */ +// ── Auto-import ─────────────────────────────────────────────── + export async function autoOrganizeAll( listFn : () => Promise, seriesData : any[], onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void ): Promise { - // Vérifier si l'import auto est activé const { autoImport } = readSettings() - if (!autoImport) return + if (!autoImport) { + logger.debug('organize', 'Import automatique désactivé — skip') + return + } if (workerRunning) { - console.log('[organize] Worker déjà en cours, skip') + logger.debug('organize', 'Worker déjà en cours — skip') return } @@ -143,7 +135,7 @@ export async function autoOrganizeAll( try { torrents = await listFn() } catch (err) { - console.error('[organize] Impossible de récupérer la liste des torrents:', err) + logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : err}`) return } @@ -159,7 +151,6 @@ export async function autoOrganizeAll( for (const t of torrents) _prevStates.set(t.hash, t.state) - // Charger organized.json pour vérifier s'il reste des torrents non organisés let organized: Record> = {} try { const orgPath = path.join(DATA_DIR, 'organized.json') @@ -175,21 +166,31 @@ export async function autoOrganizeAll( if (seedingCount === 0) return if (newlySeeding.length > 0 && !isFirstRun) { - console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`) + 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}"`) + } } else { - console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`) + logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`) } - workerRunning = true - const worker = new Worker(resolveWorkerPath()) + workerRunning = true + const worker = new Worker(resolveWorkerPath()) worker.on('message', (msg: any) => { if (msg.type === 'log') { - if (msg.level === 'error') console.error(msg.msg) - else if (msg.level === 'warn') console.warn(msg.msg) - else console.log(msg.msg) + logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) } else if (msg.type === 'result') { - console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`) + if (msg.errors.length > 0) { + logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`) + for (const e of msg.errors) { + 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)`) + } else { + logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`) + } onResult?.({ hash : msg.hash, name : msg.name, @@ -199,27 +200,34 @@ export async function autoOrganizeAll( errorFiles: msg.errors, }) } else if (msg.type === 'done') { - console.log('[organize] Worker terminé') + logger.debug('organize', 'Worker terminé') workerRunning = false worker.terminate() } }) - worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false }) - worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false }) + worker.on('error', (err) => { + logger.error('organize', `Worker erreur : ${err.message}`) + workerRunning = false + }) + worker.on('exit', (code) => { + if (code !== 0) logger.error('organize', `Worker exit avec code ${code}`) + workerRunning = false + }) worker.postMessage({ type: 'run', torrents, seriesData }) } -/** - * Organisation manuelle d'un torrent. - */ +// ── Import manuel ───────────────────────────────────────────── + export async function organizeTorrent( hash : string, name : string, savePath : string, seriesData: any[] ): Promise { + logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`) + return new Promise((resolve, reject) => { const worker = new Worker(resolveWorkerPath()) const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' } @@ -227,22 +235,34 @@ export async function organizeTorrent( worker.on('message', (msg: any) => { if (msg.type === 'log') { - if (msg.level === 'error') console.error(msg.msg) - else if (msg.level === 'warn') console.warn(msg.msg) - else console.log(msg.msg) + logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg) } else if (msg.type === 'result') { result.total = msg.total result.skipped = msg.skipped 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)`) + for (const e of msg.errors) { + 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)`) + } } else if (msg.type === 'done') { worker.terminate() resolve(result) } }) - worker.on('error', (err) => { worker.terminate(); reject(err) }) - worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) }) + worker.on('error', (err) => { + logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`) + worker.terminate() + reject(err) + }) + worker.on('exit', (code) => { + if (code !== 0) reject(new Error(`Worker exit ${code}`)) + }) worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData }) }) diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 80dafe6..bd0baa3 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -2,14 +2,13 @@ * Torrent Client Registry * ======================= * Interface commune + registry de tous les clients disponibles. - * Chaque client expose son schema de configuration (FieldDef[]) - * pour que le frontend génère le formulaire dynamiquement. */ import { randomUUID } from 'crypto' import fs from 'fs' import path from 'path' import { DATA_DIR } from '../config.js' +import { logger } from '../logger.js' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -22,36 +21,32 @@ export interface FieldDef { default? : string | number } -/** Définition statique d'un type de client (ce qu'il expose au front) */ export interface TorrentClientDefinition { - id : string // "qbittorrent", "transmission", etc. - label : string // "qBittorrent" - fields : FieldDef[] // schema du formulaire + id : string + label : string + fields : FieldDef[] } -/** Instance sauvegardée d'un client configuré par l'utilisateur */ export interface SavedClient { - uuid : string // identifiant unique de cette instance - name : string // nom custom donné par l'utilisateur - type : string // id du TorrentClientDefinition + uuid : string + name : string + type : string config: Record } -/** État normalisé d'un torrent (commun à tous les clients) */ export interface TorrentInfo { hash : string name : string state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown' - progress : number // 0-100 - size : number // bytes - downloaded: number // bytes - speed : number // bytes/s - eta : number // secondes, -1 si inconnu - save_path : string // dossier de complétion + progress : number + size : number + downloaded: number + speed : number + eta : number + save_path : string category : string } -/** Interface que chaque client doit implémenter */ export interface TorrentClientDriver { definition : TorrentClientDefinition test : (config: Record) => Promise<{ ok: boolean; message: string }> @@ -66,6 +61,7 @@ const drivers = new Map() export function registerDriver(driver: TorrentClientDriver) { drivers.set(driver.definition.id, driver) + logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`) } export function getDriver(type: string): TorrentClientDriver | undefined { @@ -76,7 +72,7 @@ export function getAvailableClients(): TorrentClientDefinition[] { return [...drivers.values()].map(d => d.definition) } -// ─── Persistence (clients enregistrés) ──────────────────────────────────────── +// ─── Persistence ────────────────────────────────────────────────────────────── const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json') @@ -103,14 +99,17 @@ export function addClient(name: string, type: string, config: Record c.uuid === uuid) const filtered = clients.filter(c => c.uuid !== uuid) if (filtered.length === clients.length) return false saveClients(filtered) + logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`) return true } @@ -118,7 +117,6 @@ export function getClient(uuid: string): SavedClient | undefined { return loadClients().find(c => c.uuid === uuid) } -/** Masque les champs password pour l'envoi au front */ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient { if (!definition) definition = getDriver(client.type)?.definition if (!definition) return client @@ -131,35 +129,40 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe return { ...client, config } } -// ─── Dispatch download vers tous les clients ────────────────────────────────── +// ─── Dispatch download ───────────────────────────────────────────────────────── export async function dispatchDownload(url: string): 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') + return [] + } + const results = [] 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` }) continue } try { await driver.add(client.config, url) + logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`) results.push({ uuid: client.uuid, name: client.name, ok: true }) } catch (err) { - results.push({ - uuid : client.uuid, - name : client.name, - ok : false, - error: err instanceof Error ? err.message : 'Erreur inconnue' - }) + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`) + results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg }) } } return results } -// ─── Dispatch list vers tous les clients ───────────────────────────────────── +// ─── Dispatch list ───────────────────────────────────────────────────────────── export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> { const clients = loadClients() @@ -169,14 +172,14 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & { const driver = getDriver(client.type) if (!driver) continue try { - // La catégorie du client est prioritaire sur la catégorie globale const clientCategory = (client.config.category as string) || category const torrents = await driver.list(client.config, clientCategory) for (const t of torrents) { results.push({ ...t, client_uuid: client.uuid, client_name: client.name }) } } catch (err) { - console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err) + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`) } } diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts index 40dbb47..1ceee1f 100644 --- a/server/torrent-clients/qbittorrent.ts +++ b/server/torrent-clients/qbittorrent.ts @@ -3,8 +3,8 @@ */ import type { TorrentClientDriver, TorrentInfo } from './index.js' +import { logger } from '../logger.js' -// Mapping état qBittorrent → état normalisé function mapState(state: string): TorrentInfo['state'] { if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading' if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding' @@ -29,7 +29,7 @@ async function qbLogin(config: Record): Promise if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`) const cookie = res.headers.get('set-cookie') ?? '' - const sid = cookie.match(/SID=([^;]+)/)?.[1] + const sid = cookie.match(/SID=([^;]+)/)?.[1] if (!sid) throw new Error('Cookie SID introuvable') return sid } @@ -39,19 +39,22 @@ const QB: TorrentClientDriver = { id : 'qbittorrent', label : 'qBittorrent', fields: [ - { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, - { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, - { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, - { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, + { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, + { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true }, + { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true }, + { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, ], }, async test(config) { try { await qbLogin(config) + logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`) return { ok: true, message: 'Connexion réussie' } } catch (err) { - return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' } + const msg = err instanceof Error ? err.message : 'Erreur inconnue' + logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) + return { ok: false, message: msg } } }, @@ -59,23 +62,26 @@ const QB: TorrentClientDriver = { try { const sid = await qbLogin(config) const res = await fetch(`${config.url}/api/v2/app/version`, { - headers: { Cookie: `SID=${sid}` } + headers: { Cookie: `SID=${sid}` }, }) if (!res.ok) return { online: false } - return { online: true, version: (await res.text()).trim() } - } catch { + const version = (await res.text()).trim() + logger.debug('qbittorrent', `Healthcheck OK — version ${version}`) + return { online: true, version } + } catch (err) { + logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`) return { online: false } } }, async list(config, category) { - const sid = await qbLogin(config) + const sid = await qbLogin(config) const params = new URLSearchParams() if (category) params.set('category', category) const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, { - headers: { Cookie: `SID=${sid}` } + headers: { Cookie: `SID=${sid}` }, }) - if (!res.ok) throw new Error(`qB list échoué: ${res.status}`) + if (!res.ok) throw new Error(`qB list échoué : ${res.status}`) const data: any[] = await res.json() return data.map(t => ({ hash : t.hash, @@ -92,7 +98,7 @@ const QB: TorrentClientDriver = { }, async add(config, url) { - const sid = await qbLogin(config) + const sid = await qbLogin(config) const form = new FormData() form.append('urls', url) if (config.category) form.append('category', String(config.category)) @@ -104,6 +110,7 @@ const QB: TorrentClientDriver = { }) const text = await res.text() if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`) + logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`) }, } diff --git a/src/App.vue b/src/App.vue index dda78b9..05b9052 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,6 @@ \ No newline at end of file diff --git a/src/components/ToastContainer.vue b/src/components/ToastContainer.vue index 1a7efd5..9741fc7 100644 --- a/src/components/ToastContainer.vue +++ b/src/components/ToastContainer.vue @@ -1,22 +1,42 @@