Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b255338d | ||
|
|
e0b09c144f | ||
|
|
418d09b07f | ||
|
|
4c7936b019 | ||
|
|
77f42b5b3e | ||
|
|
bd69f7a730 | ||
|
|
3ce6523248 | ||
|
|
2efed9dcfc | ||
|
|
88e2a0bc38 | ||
|
|
338e3f8547 | ||
|
|
d1ccc902da | ||
|
|
5e54e24b60 | ||
|
|
aae2f592b5 | ||
|
|
bd94e6a460 | ||
|
|
cbe3e1a786 | ||
|
|
7a0917e053 | ||
|
|
8ac4f7a65b | ||
|
|
453cd47e85 | ||
|
|
d5679ec529 | ||
|
|
2f0be502c3 |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
|
||||
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
|
||||
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
# Fankarr
|
||||
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -11,7 +10,7 @@
|
||||

|
||||

|
||||
|
||||
**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
|
||||
|
||||
</div>
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
+14
-15
@@ -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
|
||||
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
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
|
||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||
|
||||
# Corriger les permissions du dossier data
|
||||
chown -R "$PUID:$PGID" /app/data
|
||||
chown -R "$PUID:$PGID" /config
|
||||
|
||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||
+2
-1
@@ -4,8 +4,9 @@ import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { JWT_SECRET } from './secret.js'
|
||||
import { BASE_DIR } from './config.js'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
|
||||
const DATA_PATH = path.join(BASE_DIR, 'config', 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
export const DATA_DIR = fs.existsSync('/.dockerenv')
|
||||
? '/config'
|
||||
: path.join(BASE_DIR, 'data')
|
||||
+80
-25
@@ -12,22 +12,14 @@ import {
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR, _isBunBinary } from './config.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
// ── Base directory ─────────────────────────────────────────────
|
||||
// Binaire Bun compilé : data/ et public/ sont à côté de l'exécutable
|
||||
// Docker / dev : relatif au cwd (comportement inchangé)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
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 +99,15 @@ async function fankaiGet(endpoint: string): Promise<any> {
|
||||
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<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
@@ -117,6 +118,18 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
function computeSerieDownloadState(
|
||||
@@ -154,12 +167,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<string, Record<string, string>> = {}
|
||||
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 +189,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<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) => {
|
||||
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 +235,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<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
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 episodeTorrentMapBySnEn: Record<string, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
@@ -212,7 +256,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 +314,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
// Charger organized.json pour les badges d'état
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
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 +322,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<number>()
|
||||
const organizedBySnEn = new Set<string>()
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
const files = t.torrent_files ?? []
|
||||
@@ -286,6 +336,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 +344,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 +396,14 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
// Enrichir avec l'état d'organisation
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
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(BASE_DIR, 'config', 'torrent_final.json')
|
||||
if (fs.existsSync(tfPath))
|
||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -487,7 +542,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 +585,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}`)
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { BASE_DIR } from './config.js'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface LogEntry {
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
|
||||
const LOGS_PATH = path.join(BASE_DIR, 'config', 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
+22
-20
@@ -15,16 +15,10 @@ import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { BASE_DIR, _isBunBinary } from './config.js'
|
||||
|
||||
// Base directory : binaire Bun ou cwd (Docker/dev)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
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 +33,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(BASE_DIR, '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 } }
|
||||
@@ -139,18 +133,25 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
|
||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||
|
||||
// Lister récursivement tous les fichiers du dossier
|
||||
let files: any[]
|
||||
// Lister récursivement tous les fichiers du dossier (pagination GitLab)
|
||||
let files: any[] = []
|
||||
try {
|
||||
files = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
|
||||
)
|
||||
let page = 1
|
||||
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) {
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
if (files.length === 0) {
|
||||
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
|
||||
return
|
||||
}
|
||||
@@ -291,15 +292,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
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) {
|
||||
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) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tf.num !== undefined && epByNum.has(tf.num)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tf.num)!)
|
||||
} else if (tfNum !== undefined && epByNum.has(tfNum)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
}
|
||||
|
||||
+2
-1
@@ -9,8 +9,9 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { BASE_DIR } from './config.js'
|
||||
|
||||
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
|
||||
const SECRET_PATH = path.join(BASE_DIR, 'config', 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
|
||||
+2
-6
@@ -1,12 +1,8 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { BASE_DIR, _isBunBinary } from './config.js'
|
||||
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { BASE_DIR } from '../config.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,7 +78,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(BASE_DIR, 'config', 'torrent_clients.json')
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
|
||||
+2
-2
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user