feat(organize): scan initial mediaPath + logs fichier centralisés

- organize: scanMediaPath au démarrage — détecte les fichiers déjà présents dans Jellyfin
- organize: index filename → infohash pour le matching sans qBittorrent
- logger: remplacement des console.log/warn/error par logger dans organize.ts et index.ts
- logger: logs worker passés via logger[level] au lieu de console direct
- fix: worker déjà en cours loggé en debug au lieu de info
This commit is contained in:
MasutaYunikon committed 2026-03-11 15:09:06 +01:00
1 parent b9b940e874
commit c0fb601ef0
7 files changed
+110 -19

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

+9 -5
View File
@@ -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**
---
+8 -1
View File
@@ -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 {
+93 -13
View File
@@ -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<string, string>() // 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<string, Record<string, string>> = {}
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<void> {
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