diff --git a/.github/images/catalog.png b/.github/images/catalog.png new file mode 100644 index 0000000..81b1b98 Binary files /dev/null and b/.github/images/catalog.png differ diff --git a/.github/images/download.png b/.github/images/download.png new file mode 100644 index 0000000..2835c9d Binary files /dev/null and b/.github/images/download.png differ diff --git a/.github/images/log.png b/.github/images/log.png new file mode 100644 index 0000000..b646b6b Binary files /dev/null and b/.github/images/log.png differ diff --git a/.github/images/serie.png b/.github/images/serie.png new file mode 100644 index 0000000..260531f Binary files /dev/null and b/.github/images/serie.png differ diff --git a/README.md b/README.md index 0f3a1d7..ba36268 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai --- -[image — page catalogue avec les affiches des séries, badges ORGANISÉ/EN COURS sur certaines affiches, barre de filtre en haut] +![Catalogue](.github/images/catalog.png) > Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin. @@ -38,19 +38,19 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai --- -[image — page détail d'une série, liste des épisodes avec badges "dispo" / "✓ organisé", header de saison avec compteur "8/12 organisés"] +![Serie](.github/images/serie.png) > Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin. --- -[image — page téléchargements avec badges NON ORGANISÉ / EN COURS (3/8) / ORGANISÉ (12/12), badge rouge "2 ERREURS" avec tooltip ouvert montrant la liste des fichiers en erreur] +![Download](.github/images/download.png) > Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol. --- -[image — page logs avec table, filtres niveau INFO/WARN/ERROR, taille du fichier affichée, bouton EFFACER] +![Logs](.github/images/log.png) > Page logs : historique complet des événements avec filtres et gestion de la taille. @@ -140,7 +140,11 @@ Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont ## Runtipi -Fankarr est disponible comme app [Runtipi](https://runtipi.io) via le store communautaire. L'installation se fait en un clic depuis l'interface Runtipi. +Fankarr est disponible dans l'appstore personnalisé. Pour l'installer : + +1. Dans Runtipi, aller dans **Paramètres → App Stores** +2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore` +3. Fankarr apparaît dans le catalogue → **Installer** --- diff --git a/server/index.ts b/server/index.ts index 4ffc1df..98412e3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,7 +10,7 @@ import { sanitizeClient, dispatchDownload, dispatchList } from './torrent-clients/index.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js' -import { organizeTorrent, autoOrganizeAll } from './organize.js' +import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' registerDriver(qbittorrentDriver) @@ -476,6 +476,13 @@ app.listen(PORT, () => { } catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') } console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) + // Scan initial du mediaPath pour reconnaître les fichiers déjà présents + const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') + const { mediaPath } = readSettings() + scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err => + logger.error('organize', `scanMediaPath échoué: ${err}`) + ) + // Auto-organise toutes les 30s const autoOrganize = async () => { try { diff --git a/server/organize.ts b/server/organize.ts index 0e8d48b..6b4b111 100644 --- a/server/organize.ts +++ b/server/organize.ts @@ -5,8 +5,10 @@ * Le worker gère tout le filesystem (copy, hardlink, move). */ +import fs from 'fs' import path from 'path' import { Worker } from 'worker_threads' +import { logger } from './logger.js' // Un seul worker à la fois let workerRunning = false @@ -18,6 +20,88 @@ export interface OrganizeResult { errors : { file: string; error: string }[] } +/** + * Scan initial du mediaPath au démarrage. + * Parcourt récursivement le dossier Jellyfin et marque dans organized.json + * tous les fichiers déjà présents qui correspondent à un torrent connu. + */ +export async function scanMediaPath( + mediaPath : string, + torrentsPath: string, + organizedPath: string +): Promise<{ found: number; added: number }> { + const result = { found: 0, added: 0 } + + if (!fs.existsSync(mediaPath)) { + logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`) + return result + } + + // Charger torrent_final.json + let torrents: any[] = [] + try { + if (!fs.existsSync(torrentsPath)) return result + torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8')) + } catch { + logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json') + return result + } + + // Construire un index filename → { hash, infohash } + const filenameIndex = new Map() // filename → infohash + for (const t of torrents) { + const hash = t.infohash?.toLowerCase() + if (!hash) continue + for (const f of t.torrent_files ?? []) { + if (f.filename) filenameIndex.set(f.filename, hash) + } + // Fichier unique (torrent_files vide) → on indexe par raw name + if ((t.torrent_files ?? []).length === 0 && t.raw) { + filenameIndex.set(t.raw, hash) + } + } + + // Charger organized.json existant + let organized: Record> = {} + try { + if (fs.existsSync(organizedPath)) + organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) + } catch {} + + // Parcourir récursivement mediaPath + function walk(dir: string) { + let entries: fs.Dirent[] + try { entries = fs.readdirSync(dir, { withFileTypes: true }) } + catch { return } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && entry.name.endsWith('.mkv')) { + result.found++ + const hash = filenameIndex.get(entry.name) + if (!hash) continue + if (organized[hash]?.[entry.name]) continue // déjà connu + // Marquer comme organisé + if (!organized[hash]) organized[hash] = {} + organized[hash][entry.name] = new Date().toISOString() + result.added++ + } + } + } + + walk(mediaPath) + + if (result.added > 0) { + fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') + logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`) + } else { + logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`) + } + + return result +} + /** * Lance l'auto-organisation dans un worker thread. * listFn est appelée dans le thread principal (accès qBittorrent), @@ -28,7 +112,7 @@ export async function autoOrganizeAll( onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void ): Promise { if (workerRunning) { - console.log('[organize] Worker déjà en cours, skip') + logger.debug('organize', 'Worker déjà en cours, skip') return } @@ -36,7 +120,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) return } @@ -45,7 +129,7 @@ export async function autoOrganizeAll( const seedingCount = torrents.filter(t => t.state === 'seeding').length if (seedingCount === 0) return - console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`) + logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`) workerRunning = true const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js') @@ -54,11 +138,9 @@ export async function autoOrganizeAll( 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 'debug'|'info'|'warn'|'error']('organize', msg.msg) } else if (msg.type === 'result') { - console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`) + logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`) onResult?.({ hash : msg.hash, name : msg.name, @@ -68,19 +150,19 @@ 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) + logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`) workerRunning = false }) worker.on('exit', (code) => { - if (code !== 0) console.error(`[organize] Worker exit code ${code}`) + if (code !== 0) logger.error('organize', `Worker exit code ${code}`) workerRunning = false }) @@ -107,9 +189,7 @@ 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 'debug'|'info'|'warn'|'error']('organize', msg.msg) } else if (msg.type === 'result') { result.total = msg.total result.skipped = msg.skipped