diff --git a/.dockerignore b/.dockerignore index af73afd..4aa2887 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ node_modules .git .gitignore *.md -data +config .env .env.* !.env.example \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e4fa2ce..d47c46d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ COPY --from=build /app/dist/server /app/dist/server COPY --from=build /app/dist/client /app/public COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -RUN mkdir -p /app/data +RUN mkdir -p /config EXPOSE 3001 ENV NODE_ENV=production diff --git a/README.md b/README.md index bbee0ee..886feee 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ # Fankarr - ![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) @@ -11,7 +10,7 @@ ![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)** +**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)** Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai @@ -83,11 +82,10 @@ services: - 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 + - ./config:/config # Config, logs, base de données + - /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks) ports: - - 3001:3001 + - 9898:9898 restart: unless-stopped ``` @@ -95,15 +93,19 @@ services: docker compose up -d ``` +> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme le font Radarr et Sonarr. +> +> Vous pouvez tout à fait monter plusieurs volumes si vous le souhaitez, mais dans ce cas les hardlinks ne seront pas possibles entre deux volumes différents et Fankarr basculera automatiquement en copie. + ### Binaire autonome (Windows / Linux / macOS) Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) : -| Système | Archive | -|---|---| -| Windows x64 | `fankarr-windows-x64.zip` | -| Linux x64 | `fankarr-linux-x64.zip` | -| macOS (Apple Silicon) | `fankarr-macos.zip` | +| Système | Archive | +| --------------------- | ------------------------- | +| Windows x64 | `fankarr-windows-x64.zip` | +| Linux x64 | `fankarr-linux-x64.zip` | +| macOS (Apple Silicon) | `fankarr-macos.zip` | Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble : @@ -120,22 +122,22 @@ chmod +x fankarr ./fankarr ``` -Fankarr sera accessible sur `http://localhost:3001`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire. +Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire. ### Variables d'environnement -| Variable | Défaut | Description | -|---|---|---| -| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | -| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | -| `TZ` | — | Timezone (ex: `Europe/Paris`) | -| `PORT` | `3001` | Port d'écoute du serveur | -| `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 | +| Variable | Défaut | Description | +| ---------------- | ------------ | ----------------------------------------------------------------------- | +| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | +| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | +| `TZ` | — | Timezone (ex: `Europe/Paris`) | +| `PORT` | `9898` | Port d'écoute du serveur | +| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent | +| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser | ### Premier démarrage -1. Ouvrir `http://localhost:3001` +1. Ouvrir `http://localhost:9898` 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` @@ -173,12 +175,12 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer : ## Stack technique -| Couche | Technologie | -|---|---| -| Frontend | Vue 3 + Vite + Tailwind CSS | -| Backend | Express 5 + TypeScript | -| Auth | JWT + bcrypt | -| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | +| Couche | Technologie | +| ----------------- | ---------------------------------------- | +| Frontend | Vue 3 + Vite + Tailwind CSS | +| Backend | Express 5 + TypeScript | +| Auth | JWT + bcrypt | +| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | | Packaging binaire | Bun (self-contained, pas de Node requis) | --- diff --git a/docker-compose.yml b/docker-compose.yml index 3f90f04..468661c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,14 @@ -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 \ No newline at end of file +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: + - ./config:/config # Config, logs, base de données + - /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles) + ports: + - 9898:9898 + restart: unless-stopped + \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts index 23051be..45ba851 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -5,7 +5,7 @@ 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 DATA_PATH = path.join(process.cwd(), 'config', 'auth.json') const SALT_ROUNDS = 10 interface AuthData { diff --git a/server/index.ts b/server/index.ts index 9110f9f..701e3e6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,10 +24,15 @@ export const BASE_DIR = _isBunBinary ? path.dirname((process as any).execPath) : process.cwd() +// DATA_DIR : /config +export const DATA_DIR = fs.existsSync('/.dockerenv') + ? '/config' + : path.join(BASE_DIR, 'config') + const app = express() -const PORT = Number(process.env.PORT) || 3001 +const PORT = Number(process.env.PORT) || 9898 const FANKAI_API = 'https://metadata.fankai.fr' -const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json') +const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json') const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json' @@ -107,6 +112,15 @@ async function fankaiGet(endpoint: string): Promise { if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`) return res.json() } +function normalizeTitle(s: string): string { + return s.toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // strip accents (ï→i, é→e...) + .replace(/[^a-z0-9]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + function indexBySerie(torrents: any[]): Map { const map = new Map() for (const t of torrents) { @@ -117,6 +131,18 @@ function indexBySerie(torrents: any[]): Map { return map } +function indexByNormalizedTitle(torrents: any[]): Map { + const map = new Map() + for (const t of torrents) { + const title = t.serie_title || t.show_title + if (!title) continue + const key = normalizeTitle(title) + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(t) + } + return map +} + // ── Series ───────────────────────────────────────────────────── function computeSerieDownloadState( @@ -154,12 +180,13 @@ function computeSerieDownloadState( app.get('/api/series', requireAuth, async (_req, res) => { try { const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) - const byId = indexBySerie(torrents) + const byId = indexBySerie(torrents) + const byNormTitle = indexByNormalizedTitle(torrents) // Charger organized.json let organized: Record> = {} try { - const orgPath = path.join(process.cwd(), 'data', 'organized.json') + const orgPath = path.join(DATA_DIR, 'organized.json') if (fs.existsSync(orgPath)) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) } catch {} @@ -175,9 +202,30 @@ app.get('/api/series', requireAuth, async (_req, res) => { } } catch {} - const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? []) + const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? []) + + // Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente (ID le plus grand) + const seenTitles = new Map() + for (const serie of seriesRaw) { + const key = normalizeTitle(serie.title ?? serie.show_title ?? '') + if (!seenTitles.has(key)) { + seenTitles.set(key, serie) + } else { + const existing = seenTitles.get(key)! + const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0 + const newHasTorrents = (byId.get(serie.id) ?? byNormTitle.get(key) ?? []).length > 0 + // Préférer celle qui a des torrents, sinon la plus récente + if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie) + else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie) + } + } + const seriesList = [...seenTitles.values()] + res.json({ series: seriesList.map((serie: any) => { - const serieTorrents = byId.get(serie.id) ?? [] + // Fallback par titre normalisé si l'ID ne matche pas (Kaï→Kai migration) + const serieTorrents = byId.get(serie.id) + ?? byNormTitle.get(normalizeTitle(serie.title ?? serie.show_title ?? '')) + ?? [] return { ...serie, torrent_count : serieTorrents.length, @@ -200,10 +248,19 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) } })) - const torrents = readTorrents().filter(t => t.serie_id === id) + const allTorrents = readTorrents() + const serieMeta = allTorrents.find(t => t.serie_id === id) + const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '') + const torrents = allTorrents.filter(t => + t.serie_id === id || + normalizeTitle(t.serie_title ?? t.show_title ?? '') === normApiTitle + ) const allSeasonIds = new Set(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id)) const availableEpisodeIds = new Set() + // Fallback par season_number+episode_number pour les séries migrées Kaï→Kai + const availableBySnEn = new Set() // "sn:en" const episodeTorrentMap: Record = {} + const episodeTorrentMapBySnEn: Record = {} const seasonTorrentMap : Record = {} const integraleTorrents: any[] = [] const packEpisodesTorrents: any[] = [] @@ -212,7 +269,12 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw } for (const ep of t.resolved_episodes ?? []) { availableEpisodeIds.add(ep.episode_id) - if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref + const snEnKey = `${ep.season_number}:${ep.episode_number}` + availableBySnEn.add(snEnKey) + if (t.type === 'episode') { + episodeTorrentMap[ep.episode_id] = ref + episodeTorrentMapBySnEn[snEnKey] = ref + } } if (t.type === 'pack_saison') { const rs: any[] = t.resolved_seasons ?? [] @@ -265,7 +327,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { // Charger organized.json pour les badges d'état let organized: Record> = {} try { - const orgPath = path.join(process.cwd(), 'data', 'organized.json') + const orgPath = path.join(DATA_DIR, 'organized.json') if (fs.existsSync(orgPath)) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) } catch {} @@ -273,6 +335,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { // Construire un set des episode_id organisés // On croise resolved_episodes de chaque torrent avec organized.json const organizedEpisodeIds = new Set() + const organizedBySnEn = new Set() for (const t of torrents) { const hash = t.infohash?.toLowerCase() const files = t.torrent_files ?? [] @@ -286,6 +349,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { ) if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) { organizedEpisodeIds.add(ep.episode_id) + organizedBySnEn.add(`${ep.season_number}:${ep.episode_number}`) } } } @@ -293,9 +357,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => { const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const eps = season.episodes.map((ep: any) => ({ ...ep, - available : availableEpisodeIds.has(ep.id), - torrent : episodeTorrentMap[ep.id] ?? null, - organized : organizedEpisodeIds.has(ep.id), + available : availableEpisodeIds.has(ep.id) + || availableBySnEn.has(`${season.season_number}:${ep.episode_number}`), + torrent : episodeTorrentMap[ep.id] + ?? episodeTorrentMapBySnEn[`${season.season_number}:${ep.episode_number}`] + ?? null, + organized : organizedEpisodeIds.has(ep.id) + || organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`), })) // State saison : none / partial / complete const total = eps.filter((e: any) => e.available).length @@ -341,14 +409,14 @@ app.get('/api/downloads', requireAuth, async (_req, res) => { // Enrichir avec l'état d'organisation let organized: Record> = {} try { - const orgPath = path.join(process.cwd(), 'data', 'organized.json') + const orgPath = path.join(DATA_DIR, 'organized.json') if (fs.existsSync(orgPath)) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) } catch {} let torrentFinal: any[] = [] try { - const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json') + const tfPath = path.join(process.cwd(), 'config', 'torrent_final.json') if (fs.existsSync(tfPath)) torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8')) } catch {} @@ -487,7 +555,7 @@ app.post('/api/update', requireAuth, async (_req, res) => { app.post('/api/scan', requireAuth, async (_req, res) => { try { const { mediaPath } = readSettings() - const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') + const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH) logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) res.json({ ok: true, ...result }) @@ -530,7 +598,7 @@ app.listen(PORT, async () => { 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 ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json') const { mediaPath } = readSettings() scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err => logger.error('organize', `scanMediaPath échoué: ${err}`) diff --git a/server/logger.ts b/server/logger.ts index b4cbc74..5a25bda 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -19,7 +19,7 @@ export interface LogEntry { meta? : Record } -const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl') +const LOGS_PATH = path.join(process.cwd(), 'config', 'logs.jsonl') const MAX_LINES = 2000 // rotation auto au-delà const IS_PROD = process.env.NODE_ENV === 'production' diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 29a6c57..8b47be4 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -23,8 +23,8 @@ const BASE_DIR = _isBunBinary ? path.dirname((process as any).execPath) : process.cwd() -const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json') -const ORGANIZED_PATH = path.join(BASE_DIR, 'data', 'organized.json') +const TORRENTS_PATH = path.join(BASE_DIR, 'config', 'torrent_final.json') +const ORGANIZED_PATH = path.join(BASE_DIR, 'config', 'organized.json') // ─── Utils log ──────────────────────────────────────────────── // Le worker tourne dans un thread séparé — il passe les logs au thread principal @@ -39,7 +39,7 @@ function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'deb function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { try { - const p = path.join(process.cwd(), 'data', 'settings.json') + const p = path.join(process.cwd(), 'config', 'settings.json') if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } return JSON.parse(fs.readFileSync(p, 'utf-8')) } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } } diff --git a/server/secret.ts b/server/secret.ts index 80544b7..827d986 100644 --- a/server/secret.ts +++ b/server/secret.ts @@ -10,7 +10,7 @@ import fs from 'fs' import path from 'path' import crypto from 'crypto' -const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key') +const SECRET_PATH = path.join(process.cwd(), 'config', 'secret.key') function generateSecret(): string { return crypto.randomBytes(48).toString('hex') diff --git a/server/settings.ts b/server/settings.ts index 4aafa96..2425267 100644 --- a/server/settings.ts +++ b/server/settings.ts @@ -6,7 +6,7 @@ const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' const BASE_DIR = _isBunBinary ? path.dirname((process as any).execPath) : process.cwd() -const DATA_PATH = path.join(BASE_DIR, 'data', 'settings.json') +const DATA_PATH = path.join(BASE_DIR, 'config', 'settings.json') export interface Settings { mediaPath : string diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index 5fd6818..d3de2b3 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -77,7 +77,7 @@ export function getAvailableClients(): TorrentClientDefinition[] { // ─── Persistence (clients enregistrés) ──────────────────────────────────────── -const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json') +const CLIENTS_PATH = path.join(process.cwd(), 'config', 'torrent_clients.json') function loadClients(): SavedClient[] { try { diff --git a/vite.config.ts b/vite.config.ts index 48ccd3a..3ed4d6a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,9 +14,9 @@ export default defineConfig({ outDir: 'dist/client' }, server: { - port: 3000, + port: 3000, // ou autre port fixe proxy: { - '/api': 'http://localhost:3001' + '/api': 'http://localhost:9898' } } }) \ No newline at end of file