Compare commits

...
18 Commits
Author SHA1 Message Date
MasutaYunikon 91f5918233 Merge pull request #14 from Masutayunikon/release-please--branches--main
chore(main): release 1.6.0
2026-03-11 15:48:48 +01:00
MasutaYunikon 71e3423ad4 chore(main): release 1.6.0 2026-03-11 15:48:19 +01:00
MasutaYunikon 15b4a99c86 Merge remote-tracking branch 'origin/main' 2026-03-11 15:47:55 +01:00
MasutaYunikon 14f02d1bdd feat: add favicon 2026-03-11 15:47:46 +01:00
MasutaYunikon ac6ac46a14 Merge pull request #13 from Masutayunikon/release-please--branches--main
chore(main): release 1.5.0
2026-03-11 15:47:04 +01:00
MasutaYunikon 66f5771ca7 chore(main): release 1.5.0 2026-03-11 15:45:33 +01:00
MasutaYunikon 40a12a870b Merge remote-tracking branch 'origin/main' 2026-03-11 15:45:08 +01:00
MasutaYunikon e7afe138e1 feat(auth): génération automatique du JWT secret au démarrage
- secret.ts: génère un secret aléatoire 96 chars hex si absent
- secret.ts: persiste dans data/secret.key (mode 600)
- secret.ts: priorité env JWT_SECRET > secret.key > génération
- auth: import JWT_SECRET depuis secret.ts au lieu de process.env
- docker-compose: JWT_SECRET optionnel (géré automatiquement)
- readme: mise à jour — JWT_SECRET n'est plus obligatoire
- feat: add favicon.ico
2026-03-11 15:45:00 +01:00
MasutaYunikon 8656e7fa36 Merge pull request #12 from Masutayunikon/release-please--branches--main
chore(main): release 1.4.1
2026-03-11 15:17:46 +01:00
MasutaYunikon d21ddd6dfc chore(main): release 1.4.1 2026-03-11 15:15:58 +01:00
MasutaYunikon 867186db98 Merge remote-tracking branch 'origin/main' 2026-03-11 15:15:35 +01:00
MasutaYunikon dd320e92dc fix: logger third args 2026-03-11 15:15:25 +01:00
MasutaYunikon 44ac228ad6 Merge pull request #11 from Masutayunikon/release-please--branches--main
chore(main): release 1.4.0
2026-03-11 15:12:14 +01:00
MasutaYunikon 1a4adabef9 chore(main): release 1.4.0 2026-03-11 15:11:35 +01:00
MasutaYunikon a6fc655cb7 fix: title readme align 2026-03-11 15:11:05 +01:00
MasutaYunikon c0fb601ef0 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
2026-03-11 15:09:06 +01:00
MasutaYunikon b9b940e874 Merge remote-tracking branch 'origin/main' 2026-03-11 14:34:51 +01:00
MasutaYunikon 5a006664c0 feat: add README.md 2026-03-11 14:34:35 +01:00
12 changed files with 351 additions and 21 deletions

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

+34
View File
@@ -1,5 +1,39 @@
# Changelog
## [1.6.0](https://github.com/Masutayunikon/FanKarr/compare/v1.5.0...v1.6.0) (2026-03-11)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
## [1.5.0](https://github.com/Masutayunikon/FanKarr/compare/v1.4.1...v1.5.0) (2026-03-11)
### Features
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
## [1.4.1](https://github.com/Masutayunikon/FanKarr/compare/v1.4.0...v1.4.1) (2026-03-11)
### Bug Fixes
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
## [1.4.0](https://github.com/Masutayunikon/FanKarr/compare/v1.3.0...v1.4.0) (2026-03-11)
### Features
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
### Bug Fixes
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
## [1.3.0](https://github.com/Masutayunikon/FanKarr/compare/v1.2.0...v1.3.0) (2026-03-11)
+168 -3
View File
@@ -1,5 +1,170 @@
# Vue 3 + TypeScript + Vite
<div align="center">
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
# Fankarr
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build)
![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94)
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
</div>
---
![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.
---
## Fonctionnalités
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
- **Notifications** toast automatiques quand une organisation se termine
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Authentification** par mot de passe avec session JWT
- **Compatible Runtipi / Docker**
---
![Serie](.github/images/serie.png)
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
---
![Download](.github/images/download.png)
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
---
![Logs](.github/images/log.png)
> Page logs : historique complet des événements avec filtres et gestion de la taille.
---
## Données torrent — Scraper
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
---
## Installation
### Prérequis
- Docker + Docker Compose
- qBittorrent accessible en réseau
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
### Docker Compose
```yaml
services:
fankarr:
image: masutayunikon/fankarr:latest
container_name: fankarr
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./data:/app/data # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
ports:
- 3001:3001
restart: unless-stopped
```
```bash
docker compose up -d
```
Fankarr sera accessible sur `http://localhost:3001`.
### Variables d'environnement
| Variable | Défaut | Description |
|---|---|---|
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage
1. Ouvrir `http://localhost:3001`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath`
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
6. Retourner au catalogue et télécharger
---
## Organisation des fichiers
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
```
/media/Kai/
└── Black Lagoon Henshū/
└── Saison 001/
├── Black Lagoon Henshū S01E01.mkv
├── Black Lagoon Henshū S01E02.mkv
└── ...
```
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
---
## 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**
---
## Stack technique
| Couche | Technologie |
|---|---|
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Packaging | Docker (`node:20-slim`) + pnpm |
| Permissions | gosu (PUID/PGID) |
---
## Liens
- [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+3 -3
View File
@@ -3,9 +3,9 @@ import path from 'path'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
const JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
const SALT_ROUNDS = 10
interface AuthData {
@@ -63,7 +63,7 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
@@ -87,7 +87,7 @@ export function authLogin(req: Request, res: Response): void {
return
}
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true })
}
+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 instanceof Error ? err.message : String(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
+44
View File
@@ -0,0 +1,44 @@
/**
* secret.ts
* =========
* Génère et persiste le JWT secret dans data/secret.key
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
}
function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET
}
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret
}
// 3. Génération + sauvegarde
const secret = generateSecret()
try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
} catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err)
}
return secret
}
export const JWT_SECRET: string = loadOrCreateSecret()
+1 -1
View File
@@ -273,7 +273,7 @@ async function download(key: string, torrent_url: string | null, magnet: string
downloading.value = key
const result = await store.download(torrent_url, magnet)
downloading.value = null
if (result.success) showToast('success', 'Ajouté à qBittorrent !')
if (result.success) showToast('success', 'Téléchargement lancé')
else showToast('error', result.error ?? 'Erreur inconnue')
}