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
This commit is contained in:
1 parent
d5679ec529
commit
7a0917e053
12 files changed
+139
-70
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ node_modules
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
data
|
config
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
# Fankarr
|
# 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
|
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user