Compare commits

...
16 Commits
Author SHA1 Message Date
MasutaYunikon aae2f592b5 Merge pull request #34 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.2
2026-03-20 02:11:37 +01:00
MasutaYunikon bd94e6a460 chore(main): release 1.11.2 2026-03-20 02:11:01 +01:00
MasutaYunikon cbe3e1a786 Merge remote-tracking branch 'origin/main' 2026-03-20 02:10:38 +01:00
MasutaYunikon 7a0917e053 fix: port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve
- Port par défaut 3001 → 9898
- Volume /app/data → /config (entrypoint, docker-compose, README)
- Normalisation titres Kaï/Kai dans index.ts (catalogue + déduplication + serie detail)
- Fix pagination GitLab NFO (per_page=100 → loop multi-pages)
- Fix _is_bonus_filename avec word boundaries (\b) — évite faux positifs "national"→"ona"
- Fix dossier Bonus/Specials exclus : récupère flottants (7,5/19,5) et S00Exx → saison 0
- _EP_FLOAT_PATTERN supporte virgules (7,5) en plus des points (7.5)
- Ajout strict_season dans find_episode/resolve_ep — évite collision ep1 S1 vs ep1 S2 (JoJo Parties)
- Ajout SEASON_EP_OFFSET pour Hokuto No Ken (saison 02 torrent → +16 offset API)
- Fallback non-strict si strict_season échoue (Hokuto ep 5-16 S1)
- Fix collect_covered_episodes : fallback torrent.serie_id si absent dans resolved_episodes
- Fix s6_verify : get_all_specials + compteur S00 séparé, déduplication Kaï/Kai, format OK/TOTAL
- Ajout MANUAL_TORRENTS dans s4 (MHA Film 4 You're Next URL Nyaa)
- Injection torrents locaux manquants dans pipeline (Reborn! Kaï via fichier local)
- Fix s5 : résolution directe torrents manuels season_number=0
- Fix s2 : pack_saga_slash et pack_partie_slash → pack_integrale, lookahead (?<!/) sur episode_num_tiret_apres
- Fix s5 skip condition : re-résoudre si resolved_count < files_count (Fairy Tail)
- README : note hardlinks volumes multiples, port, chemins config
2026-03-20 02:10:27 +01:00
MasutaYunikon 8ac4f7a65b Merge pull request #33 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.1
2026-03-14 00:59:14 +01:00
MasutaYunikon 453cd47e85 chore(main): release 1.11.1 2026-03-14 00:58:57 +01:00
MasutaYunikon d5679ec529 Merge remote-tracking branch 'origin/main' 2026-03-14 00:58:37 +01:00
MasutaYunikon 2f0be502c3 fix: worker now work on multiple github files pages 2026-03-14 00:58:29 +01:00
MasutaYunikon ec971915b5 Merge pull request #32 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.0
2026-03-13 19:03:34 +01:00
MasutaYunikon bb71b241d1 chore(main): release 1.11.0 2026-03-13 19:03:16 +01:00
MasutaYunikon 7aa01755ec Merge remote-tracking branch 'origin/main' 2026-03-13 19:02:46 +01:00
MasutaYunikon 8ce0ca1f24 feat: fix worker for multiple series in integral (one piece fix ?) 2026-03-13 19:02:38 +01:00
MasutaYunikon 620aac6b38 Merge pull request #31 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.3
2026-03-13 04:35:00 +01:00
MasutaYunikon e99e413e4e chore(main): release 1.10.3 2026-03-13 04:34:21 +01:00
MasutaYunikon fb578cf9f9 Merge remote-tracking branch 'origin/main' 2026-03-13 04:34:00 +01:00
MasutaYunikon 78e3055344 fix: worker path 2026-03-13 04:33:51 +01:00
14 changed files with 217 additions and 84 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ node_modules
.git .git
.gitignore .gitignore
*.md *.md
data config
.env .env
.env.* .env.*
!.env.example !.env.example
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
### Bug Fixes
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
### Bug Fixes
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
### Features
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
### Bug Fixes
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13) ## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
+1 -1
View File
@@ -24,7 +24,7 @@ COPY --from=build /app/dist/server /app/dist/server
COPY --from=build /app/dist/client /app/public COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data RUN mkdir -p /config
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production ENV NODE_ENV=production
+28 -26
View File
@@ -2,7 +2,6 @@
# Fankarr # Fankarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version) ![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 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) ![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
@@ -83,11 +82,10 @@ services:
- PGID=1000 # GID de votre utilisateur (id -g) - PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone - TZ=Europe/Paris # Votre timezone
volumes: volumes:
- ./data:/app/data # Config, logs, base de données - ./config:/config # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin - /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
ports: ports:
- 3001:3001 - 9898:9898
restart: unless-stopped restart: unless-stopped
``` ```
@@ -95,15 +93,19 @@ services:
docker compose up -d 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) ### 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) : Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive | | Système | Archive |
|---|---| | --------------------- | ------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` | | Windows x64 | `fankarr-windows-x64.zip` |
| Linux x64 | `fankarr-linux-x64.zip` | | Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` | | macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble : Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
@@ -120,22 +122,22 @@ chmod +x fankarr
./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 ### Variables d'environnement
| Variable | Défaut | Description | | Variable | Défaut | Description |
|---|---|---| | ---------------- | ------------ | ----------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | | `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | | `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) | | `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `3001` | Port d'écoute du serveur | | `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent | | `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 | | `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage ### Premier démarrage
1. Ouvrir `http://localhost:3001` 1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion 2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent 3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath` 4. Vérifier les chemins `completePath` et `mediaPath`
@@ -173,12 +175,12 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
## Stack technique ## Stack technique
| Couche | Technologie | | Couche | Technologie |
|---|---| | ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS | | Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript | | Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt | | Auth | JWT + bcrypt |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | | Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) | | Packaging binaire | Bun (self-contained, pas de Node requis) |
--- ---
+14 -15
View File
@@ -1,15 +1,14 @@
services: fankarr:
fankarr: image: masutayunikon/fankarr:latest
image: masutayunikon/fankarr:latest container_name: fankarr
container_name: fankarr environment:
environment: - PUID=1000 # UID de votre utilisateur (id -u)
- PUID=1000 # UID de votre utilisateur (id -u) - PGID=1000 # GID de votre utilisateur (id -g)
- PGID=1000 # GID de votre utilisateur (id -g) - TZ=Europe/Paris # Votre timezone
- TZ=Europe/Paris # Votre timezone volumes:
volumes: - ./config:/config # Config, logs, base de données
- ./data:/app/data # Config, logs, base de données - /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
- /votre/chemin/media:/media/Kai # Destination Jellyfin ports:
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent - 9898:9898
ports: restart: unless-stopped
- 3001:3001
restart: unless-stopped
+1 -1
View File
@@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js' 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 const SALT_ROUNDS = 10
interface AuthData { interface AuthData {
+84 -16
View File
@@ -24,10 +24,15 @@ export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath) ? path.dirname((process as any).execPath)
: process.cwd() : process.cwd()
// DATA_DIR : /config
export const DATA_DIR = fs.existsSync('/.dockerenv')
? '/config'
: path.join(BASE_DIR, 'config')
const app = express() 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 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 const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json' ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
@@ -107,6 +112,15 @@ async function fankaiGet(endpoint: string): Promise<any> {
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`) if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json() 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<number, any[]> { function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>() const map = new Map<number, any[]>()
for (const t of torrents) { for (const t of torrents) {
@@ -117,6 +131,18 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
return map return map
} }
function indexByNormalizedTitle(torrents: any[]): Map<string, any[]> {
const map = new Map<string, any[]>()
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 ───────────────────────────────────────────────────── // ── Series ─────────────────────────────────────────────────────
function computeSerieDownloadState( function computeSerieDownloadState(
@@ -154,12 +180,13 @@ function computeSerieDownloadState(
app.get('/api/series', requireAuth, async (_req, res) => { app.get('/api/series', requireAuth, async (_req, res) => {
try { try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) 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 // Charger organized.json
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
@@ -175,9 +202,30 @@ app.get('/api/series', requireAuth, async (_req, res) => {
} }
} catch {} } 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<string, any>()
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) => { 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 { return {
...serie, ...serie,
torrent_count : serieTorrents.length, 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 ?? []) } 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<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id)) const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
const availableEpisodeIds = new Set<number>() const availableEpisodeIds = new Set<number>()
// Fallback par season_number+episode_number pour les séries migrées Kaï→Kai
const availableBySnEn = new Set<string>() // "sn:en"
const episodeTorrentMap: Record<number, any> = {} const episodeTorrentMap: Record<number, any> = {}
const episodeTorrentMapBySnEn: Record<string, any> = {}
const seasonTorrentMap : Record<number, any> = {} const seasonTorrentMap : Record<number, any> = {}
const integraleTorrents: any[] = [] const integraleTorrents: any[] = []
const packEpisodesTorrents: 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 } const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
for (const ep of t.resolved_episodes ?? []) { for (const ep of t.resolved_episodes ?? []) {
availableEpisodeIds.add(ep.episode_id) 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') { if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? [] 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 // Charger organized.json pour les badges d'état
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
@@ -273,6 +335,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
// Construire un set des episode_id organisés // Construire un set des episode_id organisés
// On croise resolved_episodes de chaque torrent avec organized.json // On croise resolved_episodes de chaque torrent avec organized.json
const organizedEpisodeIds = new Set<number>() const organizedEpisodeIds = new Set<number>()
const organizedBySnEn = new Set<string>()
for (const t of torrents) { for (const t of torrents) {
const hash = t.infohash?.toLowerCase() const hash = t.infohash?.toLowerCase()
const files = t.torrent_files ?? [] 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) { if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
organizedEpisodeIds.add(ep.episode_id) 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 enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => ({ const eps = season.episodes.map((ep: any) => ({
...ep, ...ep,
available : availableEpisodeIds.has(ep.id), available : availableEpisodeIds.has(ep.id)
torrent : episodeTorrentMap[ep.id] ?? null, || availableBySnEn.has(`${season.season_number}:${ep.episode_number}`),
organized : organizedEpisodeIds.has(ep.id), 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 // State saison : none / partial / complete
const total = eps.filter((e: any) => e.available).length 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 // Enrichir avec l'état d'organisation
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
let torrentFinal: any[] = [] let torrentFinal: any[] = []
try { 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)) if (fs.existsSync(tfPath))
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8')) torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
} catch {} } catch {}
@@ -487,7 +555,7 @@ app.post('/api/update', requireAuth, async (_req, res) => {
app.post('/api/scan', requireAuth, async (_req, res) => { app.post('/api/scan', requireAuth, async (_req, res) => {
try { try {
const { mediaPath } = readSettings() 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) const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
@@ -530,7 +598,7 @@ app.listen(PORT, async () => {
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents // 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() const { mediaPath } = readSettings()
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err => scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
logger.error('organize', `scanMediaPath échoué: ${err}`) logger.error('organize', `scanMediaPath échoué: ${err}`)
+1 -1
View File
@@ -19,7 +19,7 @@ export interface LogEntry {
meta? : Record<string, any> meta? : Record<string, any>
} }
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 MAX_LINES = 2000 // rotation auto au-delà
const IS_PROD = process.env.NODE_ENV === 'production' const IS_PROD = process.env.NODE_ENV === 'production'
+50 -15
View File
@@ -23,8 +23,8 @@ const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath) ? path.dirname((process as any).execPath)
: process.cwd() : process.cwd()
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json') const TORRENTS_PATH = path.join(BASE_DIR, 'config', 'torrent_final.json')
const ORGANIZED_PATH = path.join(BASE_DIR, 'data', 'organized.json') const ORGANIZED_PATH = path.join(BASE_DIR, 'config', 'organized.json')
// ─── Utils log ──────────────────────────────────────────────── // ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal // 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 } { function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try { 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 } if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8')) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } } } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
@@ -139,18 +139,25 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`) log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier // Lister récursivement tous les fichiers du dossier (pagination GitLab)
let files: any[] let files: any[] = []
try { try {
files = await fetchJson( let page = 1
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main` while (true) {
) const batch = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
)
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch)
if (batch.length < 100) break
page++
}
} catch (err) { } catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`) warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
return return
} }
if (!Array.isArray(files) || files.length === 0) { if (files.length === 0) {
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`) warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
return return
} }
@@ -218,8 +225,35 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
// ── Téléchargement NFO/images depuis GitLab ─────────────── // ── Téléchargement NFO/images depuis GitLab ───────────────
if (nfoSupport) { if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle) // Collecter tous les titres de séries distincts depuis resolved_episodes
await downloadGitlabFolder(serieTitle, serieDestRoot) // (un pack_integrale peut mélanger plusieurs séries, ex: One Piece Yabai + Kaï)
const serieTitlesMap = new Map<number, string>()
// Titre principal du torrent
serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle)
// Titres alternatifs depuis resolved_episodes
// resolved_episodes n'a pas toujours serie_title → on cherche dans torrent_final.json
const allTorrents: any[] = (() => {
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
})()
const serieIdToTitle = new Map<number, string>()
for (const t of allTorrents) {
if (t.serie_id && t.serie_title) serieIdToTitle.set(t.serie_id, t.serie_title)
}
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.serie_id && !serieTitlesMap.has(ep.serie_id)) {
const title = ep.serie_title ?? serieIdToTitle.get(ep.serie_id)
if (title) serieTitlesMap.set(ep.serie_id, title)
}
}
// Télécharger NFO pour chaque série distincte
for (const title of serieTitlesMap.values()) {
const serieDestRoot = path.join(mediaPath, title)
await downloadGitlabFolder(title, serieDestRoot)
}
} }
// ── Cas fichier unique ──────────────────────────────────── // ── Cas fichier unique ────────────────────────────────────
@@ -264,15 +298,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
const epByNum = new Map<number, number>() // ep_number → season_number const epByNum = new Map<number, number>() // ep_number → season_number
for (const ep of torrent.resolved_episodes ?? []) { for (const ep of torrent.resolved_episodes ?? []) {
if (ep.episode_number !== undefined && ep.season_number !== undefined) if (ep.episode_number !== undefined && ep.season_number !== undefined)
epByNum.set(ep.episode_number, ep.season_number) epByNum.set(Number(ep.episode_number), ep.season_number)
} }
for (const tf of torrentFiles) { for (const tf of torrentFiles) {
const sn: number | null = tf.season_number ?? null const sn: number | null = tf.season_number != null ? Number(tf.season_number) : null
const tfNum = tf.num !== undefined ? Number(tf.num) : undefined
if (sn !== null) { if (sn !== null) {
filenameSeasonMap.set(tf.filename, sn) filenameSeasonMap.set(tf.filename, sn)
} else if (tf.num !== undefined && epByNum.has(tf.num)) { } else if (tfNum !== undefined && epByNum.has(tfNum)) {
filenameSeasonMap.set(tf.filename, epByNum.get(tf.num)!) filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
} else if (torrent.season_number !== undefined) { } else if (torrent.season_number !== undefined) {
filenameSeasonMap.set(tf.filename, torrent.season_number) filenameSeasonMap.set(tf.filename, torrent.season_number)
} }
+4 -3
View File
@@ -17,9 +17,10 @@ let workerRunning = false
// En binaire Bun compilé : le worker est à côté de l'exécutable // En binaire Bun compilé : le worker est à côté de l'exécutable
// En Docker/dev : à côté du fichier courant // En Docker/dev : à côté du fichier courant
function resolveWorkerPath(): string { function resolveWorkerPath(): string {
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' // Si Bun est présent → le worker est toujours à côté de l'exécutable
&& path.dirname((process as any).execPath) !== process.cwd() // (que ce soit un binaire compilé ou `bun run` en dev)
if (_isBunBinary) { // En Node/tsx → on utilise import.meta.url
if (typeof (globalThis as any).Bun !== 'undefined') {
return path.join(path.dirname((process as any).execPath), 'organize-worker.js') return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
} }
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js') return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
+1 -1
View File
@@ -10,7 +10,7 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import crypto from 'crypto' 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 { function generateSecret(): string {
return crypto.randomBytes(48).toString('hex') return crypto.randomBytes(48).toString('hex')
+1 -1
View File
@@ -6,7 +6,7 @@ const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
const BASE_DIR = _isBunBinary const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath) ? path.dirname((process as any).execPath)
: process.cwd() : 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 { export interface Settings {
mediaPath : string mediaPath : string
+1 -1
View File
@@ -77,7 +77,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
// ─── Persistence (clients enregistrés) ──────────────────────────────────────── // ─── 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[] { function loadClients(): SavedClient[] {
try { try {
+2 -2
View File
@@ -14,9 +14,9 @@ export default defineConfig({
outDir: 'dist/client' outDir: 'dist/client'
}, },
server: { server: {
port: 3000, port: 3000, // ou autre port fixe
proxy: { proxy: {
'/api': 'http://localhost:3001' '/api': 'http://localhost:9898'
} }
} }
}) })