Compare commits

...
45 Commits
Author SHA1 Message Date
MasutaYunikon ac6ac46a14 Merge pull request #13 from Masutayunikon/release-please--branches--main
chore(main): release 1.5.0
2026-03-11 15:47:04 +01:00
MasutaYunikon 66f5771ca7 chore(main): release 1.5.0 2026-03-11 15:45:33 +01:00
MasutaYunikon 40a12a870b Merge remote-tracking branch 'origin/main' 2026-03-11 15:45:08 +01:00
MasutaYunikon e7afe138e1 feat(auth): génération automatique du JWT secret au démarrage
- secret.ts: génère un secret aléatoire 96 chars hex si absent
- secret.ts: persiste dans data/secret.key (mode 600)
- secret.ts: priorité env JWT_SECRET > secret.key > génération
- auth: import JWT_SECRET depuis secret.ts au lieu de process.env
- docker-compose: JWT_SECRET optionnel (géré automatiquement)
- readme: mise à jour — JWT_SECRET n'est plus obligatoire
- feat: add favicon.ico
2026-03-11 15:45:00 +01:00
MasutaYunikon 8656e7fa36 Merge pull request #12 from Masutayunikon/release-please--branches--main
chore(main): release 1.4.1
2026-03-11 15:17:46 +01:00
MasutaYunikon d21ddd6dfc chore(main): release 1.4.1 2026-03-11 15:15:58 +01:00
MasutaYunikon 867186db98 Merge remote-tracking branch 'origin/main' 2026-03-11 15:15:35 +01:00
MasutaYunikon dd320e92dc fix: logger third args 2026-03-11 15:15:25 +01:00
MasutaYunikon 44ac228ad6 Merge pull request #11 from Masutayunikon/release-please--branches--main
chore(main): release 1.4.0
2026-03-11 15:12:14 +01:00
MasutaYunikon 1a4adabef9 chore(main): release 1.4.0 2026-03-11 15:11:35 +01:00
MasutaYunikon a6fc655cb7 fix: title readme align 2026-03-11 15:11:05 +01:00
MasutaYunikon c0fb601ef0 feat(organize): scan initial mediaPath + logs fichier centralisés
- organize: scanMediaPath au démarrage — détecte les fichiers déjà présents dans Jellyfin
- organize: index filename → infohash pour le matching sans qBittorrent
- logger: remplacement des console.log/warn/error par logger dans organize.ts et index.ts
- logger: logs worker passés via logger[level] au lieu de console direct
- fix: worker déjà en cours loggé en debug au lieu de info
2026-03-11 15:09:06 +01:00
MasutaYunikon b9b940e874 Merge remote-tracking branch 'origin/main' 2026-03-11 14:34:51 +01:00
MasutaYunikon 5a006664c0 feat: add README.md 2026-03-11 14:34:35 +01:00
MasutaYunikon 43c4422b4f Merge pull request #10 from Masutayunikon/release-please--branches--main
chore(main): release 1.3.0
2026-03-11 14:25:31 +01:00
MasutaYunikon 6410fcee1f chore(main): release 1.3.0 2026-03-11 14:25:03 +01:00
MasutaYunikon 3b281b60ee Merge remote-tracking branch 'origin/main' 2026-03-11 14:24:42 +01:00
MasutaYunikon 0e6e13332f feat(serie): badges état organisation sur épisodes et saisons
- api: /api/series/:id enrichi avec organized + organized_state/count par saison
- ui: badge ✓ organisé vert sur chaque épisode organisé
- ui: compteur organisés dans le header de saison (partial/complete)
- fix: suppression fill="none" restants dans SerieView.vue
2026-03-11 14:24:33 +01:00
MasutaYunikon b8f0984ec6 Merge pull request #9 from Masutayunikon/release-please--branches--main
chore(main): release 1.2.0
2026-03-11 14:11:33 +01:00
MasutaYunikon e3bca6be87 chore(main): release 1.2.0 2026-03-11 14:10:26 +01:00
MasutaYunikon b88e020fb5 feat(ui): états catalogue + logs + badges erreurs downloads
- catalog: badge ORGANISÉ/PARTIEL/EN COURS sur les affiches (download_state)
- catalog: filtres Téléchargés et En cours
- catalog: icône logs dans le header
- catalog: polling notifications organisation (toast auto)
- api: /api/series enrichi avec download_state par série
- api: /api/logs + /api/logs/clear avec filtre level/source/limit
- api: /api/downloads enrichi avec errorFiles depuis recentOrganized
- logger: module centralisé logs.jsonl, rotation 2000 lignes, debug masqué en prod
- downloads: badge erreurs avec tooltip fichiers en erreur au hover
- downloads: badge organisation NON ORGANISÉ/EN COURS/ORGANISÉ avec compteur
- logs: page /logs avec table, filtres niveau/source, choix 100/500/tout, taille fichier, clear
- organize: callback onResult expose errorFiles
- organize-worker: logs candidats absents passés en debug
2026-03-11 14:10:04 +01:00
MasutaYunikon 7f3f8656b3 Merge remote-tracking branch 'origin/main' 2026-03-11 13:44:59 +01:00
MasutaYunikon 97da422037 feat(organize): worker thread + badges statut + notifications
- api: /api/organize/recent pour les notifications frontend
- api: /api/system pour détecter l'environnement Docker
- ui: badge organisation (NON ORGANISÉ / EN COURS / ORGANISÉ) dans DownloadsView
- ui: icône download avec compteur actifs dans le header CatalogView
- ui: toast automatique quand l'auto-organisation se termine
- ui: paths en lecture seule avec badge DOCKER dans SettingsView
2026-03-11 13:44:51 +01:00
MasutaYunikon 43dbfefc7a Merge pull request #8 from Masutayunikon/release-please--branches--main
chore(main): release 1.1.0
2026-03-11 00:50:58 +01:00
MasutaYunikon 0c576fb59f chore(main): release 1.1.0 2026-03-11 00:46:28 +01:00
MasutaYunikon c097cd4d1f fix: skip excluded folders 2026-03-11 00:46:12 +01:00
MasutaYunikon e67efe39f4 Merge remote-tracking branch 'origin/main' 2026-03-11 00:35:49 +01:00
MasutaYunikon 237530321e feat: make the organizer threaded to not consumme api main thread 2026-03-11 00:35:41 +01:00
MasutaYunikon 91c27cf2f3 Merge pull request #7 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.6
2026-03-10 23:58:26 +01:00
MasutaYunikon 19d09de88e chore(main): release 1.0.6 2026-03-10 23:58:14 +01:00
MasutaYunikon 5850265f6d Merge remote-tracking branch 'origin/main' 2026-03-10 23:57:54 +01:00
MasutaYunikon 27f55ab2be fix: remove user creation in entrypoint.sh 2026-03-10 23:57:46 +01:00
MasutaYunikon f871644330 Merge pull request #6 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.5
2026-03-10 23:52:29 +01:00
MasutaYunikon a5e97088c4 chore(main): release 1.0.5 2026-03-10 23:52:14 +01:00
MasutaYunikon ca4542d71b Merge remote-tracking branch 'origin/main' 2026-03-10 23:51:58 +01:00
MasutaYunikon 61418c6f77 fix: add user permissions when the scripts is launched 2026-03-10 23:51:50 +01:00
MasutaYunikon 1d89a8f61e Merge pull request #5 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.4
2026-03-10 23:37:40 +01:00
MasutaYunikon db5454393f chore(main): release 1.0.4 2026-03-10 23:37:16 +01:00
MasutaYunikon e18c6a1c54 fix: add log for debug 2026-03-10 23:37:06 +01:00
MasutaYunikon 49d60aeabd Merge remote-tracking branch 'origin/main' 2026-03-10 23:36:57 +01:00
MasutaYunikon c78537c9d8 fix: add log for debug 2026-03-10 23:36:46 +01:00
MasutaYunikon def689a1ca Merge pull request #4 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.3
2026-03-10 23:27:12 +01:00
MasutaYunikon 8bddc61d41 chore(main): release 1.0.3 2026-03-10 23:25:18 +01:00
MasutaYunikon b6587acaf4 Merge remote-tracking branch 'origin/main' 2026-03-10 23:25:01 +01:00
MasutaYunikon dd1ead4568 fix: organize file 2026-03-10 23:24:51 +01:00
20 changed files with 1477 additions and 354 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

+83
View File
@@ -1,5 +1,88 @@
# Changelog # Changelog
## [1.5.0](https://github.com/Masutayunikon/FanKarr/compare/v1.4.1...v1.5.0) (2026-03-11)
### Features
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
## [1.4.1](https://github.com/Masutayunikon/FanKarr/compare/v1.4.0...v1.4.1) (2026-03-11)
### Bug Fixes
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
## [1.4.0](https://github.com/Masutayunikon/FanKarr/compare/v1.3.0...v1.4.0) (2026-03-11)
### Features
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
### Bug Fixes
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
## [1.3.0](https://github.com/Masutayunikon/FanKarr/compare/v1.2.0...v1.3.0) (2026-03-11)
### Features
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
## [1.2.0](https://github.com/Masutayunikon/FanKarr/compare/v1.1.0...v1.2.0) (2026-03-11)
### Features
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
## [1.1.0](https://github.com/Masutayunikon/FanKarr/compare/v1.0.6...v1.1.0) (2026-03-10)
### Features
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
### Bug Fixes
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
## [1.0.6](https://github.com/Masutayunikon/FanKarr/compare/v1.0.5...v1.0.6) (2026-03-10)
### Bug Fixes
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
## [1.0.5](https://github.com/Masutayunikon/FanKarr/compare/v1.0.4...v1.0.5) (2026-03-10)
### Bug Fixes
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
## [1.0.4](https://github.com/Masutayunikon/FanKarr/compare/v1.0.3...v1.0.4) (2026-03-10)
### Bug Fixes
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
## [1.0.3](https://github.com/Masutayunikon/FanKarr/compare/v1.0.2...v1.0.3) (2026-03-10)
### Bug Fixes
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10) ## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10)
+7 -3
View File
@@ -15,13 +15,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build RUN pnpm run build
# ── Production ───────────────────────────────────────────────── # ── Production ─────────────────────────────────────────────────
FROM base FROM node:20-slim
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=prod-deps /app/node_modules /app/node_modules COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist/server /app/dist/server 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
RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production ENV NODE_ENV=production
CMD ["node", "dist/server/index.js"] ENTRYPOINT ["/entrypoint.sh"]
+166 -3
View File
@@ -1,5 +1,168 @@
# Vue 3 + TypeScript + Vite <div align="center">
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. # Fankarr
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
![GitHub Actions](https://img.shields.io/github/actions/workflow/status/masutayunikon/fankarr/containerize.yml?style=flat-square&label=build)
![License](https://img.shields.io/github/license/masutayunikon/fankarr?style=flat-square&color=5a7a94)
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
</div>
---
![Catalogue](.github/images/catalog.png)
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
---
## Fonctionnalités
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
- **Notifications** toast automatiques quand une organisation se termine
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Authentification** par mot de passe avec session JWT
- **Compatible Runtipi / Docker**
---
![Serie](.github/images/serie.png)
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
---
![Download](.github/images/download.png)
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
---
![Logs](.github/images/log.png)
> Page logs : historique complet des événements avec filtres et gestion de la taille.
---
## Données torrent — Scraper
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
---
## Installation
### Prérequis
- Docker + Docker Compose
- qBittorrent accessible en réseau
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
### Docker Compose
```yaml
services:
fankarr:
image: masutayunikon/fankarr:latest
container_name: fankarr
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./data:/app/data # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
ports:
- 3001:3001
restart: unless-stopped
```
```bash
docker compose up -d
```
Fankarr sera accessible sur `http://localhost:3001`.
### Variables d'environnement
| Variable | Défaut | Description |
|---|---|---|
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage
1. Ouvrir `http://localhost:3001`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath`
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
6. Retourner au catalogue et télécharger
---
## Organisation des fichiers
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
```
/media/Kai/
└── Black Lagoon Henshū/
└── Saison 001/
├── Black Lagoon Henshū S01E01.mkv
├── Black Lagoon Henshū S01E02.mkv
└── ...
```
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
---
## Runtipi
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
1. Dans Runtipi, aller dans **Paramètres → App Stores**
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
3. Fankarr apparaît dans le catalogue → **Installer**
---
## Stack technique
| Couche | Technologie |
|---|---|
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Packaging | Docker (`node:20-slim`) + pnpm |
| Permissions | gosu (PUID/PGID) |
---
## Liens
- [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data
exec gosu "$PUID:$PGID" node dist/server/index.js
+3 -3
View File
@@ -3,9 +3,9 @@ import path from 'path'
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express' 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(), 'data', 'auth.json')
const JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
interface AuthData { interface AuthData {
@@ -63,7 +63,7 @@ export function authSetup(req: Request, res: Response): void {
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS) const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
writeAuth({ username, passwordHash }) writeAuth({ username, passwordHash })
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' }) const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true }) res.json({ success: true })
} }
@@ -87,7 +87,7 @@ export function authLogin(req: Request, res: Response): void {
return return
} }
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' }) const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' }) res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true }) res.json({ success: true })
} }
+211 -16
View File
@@ -10,7 +10,8 @@ import {
sanitizeClient, dispatchDownload, dispatchList sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients/index.js' } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll } from './organize.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
registerDriver(qbittorrentDriver) registerDriver(qbittorrentDriver)
@@ -108,16 +109,73 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
} }
// ── Series ───────────────────────────────────────────────────── // ── Series ─────────────────────────────────────────────────────
function computeSerieDownloadState(
serieTorrents : any[],
organized : Record<string, Record<string, string>>,
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
): 'none' | 'downloading' | 'partial' | 'complete' {
if (serieTorrents.length === 0) return 'none'
// Un torrent de la série est en cours de téléchargement ?
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
if (hasActive) return 'downloading'
// Compter les fichiers attendus vs organisés
let totalFiles = 0
let doneFiles = 0
for (const t of serieTorrents) {
const files: any[] = t.torrent_files ?? []
if (files.length === 0) {
// Fichier unique
totalFiles++
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
} else {
totalFiles += files.length
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
}
}
if (totalFiles === 0) return 'none'
if (doneFiles === 0) return 'none'
if (doneFiles >= totalFiles) return 'complete'
return 'partial'
}
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)
// Charger organized.json
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Récupérer les hashes actifs depuis qB (best effort)
const activeTorrents = new Set<string>()
try {
const { category } = readSettings()
const active = await dispatchList(category ?? 'fankai')
for (const t of active) {
if (t.state === 'downloading' && t.hash)
activeTorrents.add(t.hash.toLowerCase())
}
} catch {}
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? []) const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
res.json({ series: seriesList.map((serie: any) => ({ res.json({ series: seriesList.map((serie: any) => {
...serie, const serieTorrents = byId.get(serie.id) ?? []
torrent_count: byId.get(serie.id)?.length ?? 0, return {
has_torrents : byId.has(serie.id) ...serie,
}))}) torrent_count : serieTorrents.length,
has_torrents : serieTorrents.length > 0,
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
}
})})
} catch (err) { } catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
@@ -195,15 +253,55 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
}).join(' · ') }).join(' · ')
} }
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({ // Charger organized.json pour les badges d'état
...season, let organized: Record<string, Record<string, string>> = {}
torrent: seasonTorrentMap[season.id] ?? null, try {
episodes: season.episodes.map((ep: any) => ({ const orgPath = path.join(process.cwd(), 'data', 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Construire un set des episode_id organisés
// On croise resolved_episodes de chaque torrent avec organized.json
const organizedEpisodeIds = new Set<number>()
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
const files = t.torrent_files ?? []
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
for (const ep of t.resolved_episodes ?? []) {
// Vérifier si le fichier de cet épisode est organisé
const epFile = files.find((f: any) =>
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
)
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
organizedEpisodeIds.add(ep.episode_id)
}
}
}
const enrichedSeasons = seasonsWithEpisodes.map((season: 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 torrent : episodeTorrentMap[ep.id] ?? null,
organized : organizedEpisodeIds.has(ep.id),
})) }))
})) // State saison : none / partial / complete
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const seasonOrganizedState =
orgCount === 0 ? 'none' :
orgCount >= total && total > 0 ? 'complete' : 'partial'
return {
...season,
torrent : seasonTorrentMap[season.id] ?? null,
organized_state : seasonOrganizedState,
organized_count : orgCount,
episodes : eps,
}
})
const heroBundles: any[] = [ const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })), ...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
@@ -230,13 +328,75 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
try { try {
const { category } = readSettings() const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai') const torrents = await dispatchList(category ?? 'fankai')
res.json(torrents)
// Enrichir avec l'état d'organisation
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', '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')
if (fs.existsSync(tfPath))
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
} catch {}
const enriched = torrents.map((t: any) => {
const orgFiles = organized[t.hash] ?? {}
const meta = torrentFinal.find((m: any) =>
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
)
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
const doneFiles = Object.keys(orgFiles).length
let organizeState: 'none' | 'partial' | 'done' = 'none'
if (doneFiles >= totalFiles) organizeState = 'done'
else if (doneFiles > 0) organizeState = 'partial'
// Récupérer les erreurs depuis recentOrganized
const notif = recentOrganized.find(n => n.hash === t.hash)
const errorFiles = notif?.errorFiles ?? []
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
})
res.json(enriched)
} catch (err) { } catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
} }
}) })
// ── Organize ─────────────────────────────────────────────────── // ── Résultats d'organisation récents (pour notifications frontend) ──
interface OrganizeNotif {
hash : string
name : string
done : number
skipped : number
errors : number
errorFiles : { file: string; error: string }[]
at : string
}
const recentOrganized: OrganizeNotif[] = []
const MAX_NOTIFS = 20
function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
}
app.get('/api/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
// Vider les notifs lues
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
res.json({ ok: true })
})
app.post('/api/organize', requireAuth, async (req, res) => { app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { if (!hash || !name || !save_path) {
@@ -252,6 +412,27 @@ app.post('/api/organize', requireAuth, async (req, res) => {
}) })
// ── Torrents status ──────────────────────────────────────────── // ── Torrents status ────────────────────────────────────────────
// ── Logs ──────────────────────────────────────────────────────
app.get('/api/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
const source = (req.query.source as string) || undefined
const entries = readLogs({ limit, level: level as any, source })
res.json({ entries, size: logsFileSize() })
})
app.post('/api/logs/clear', requireAuth, (_req, res) => {
clearLogs()
logger.info('api', 'Logs effacés')
res.json({ ok: true })
})
// ── System info ────────────────────────────────────────────────
app.get('/api/system', requireAuth, (_req, res) => {
const isDocker = fs.existsSync('/.dockerenv')
res.json({ isDocker })
})
app.get('/api/torrents/status', requireAuth, (_req, res) => { app.get('/api/torrents/status', requireAuth, (_req, res) => {
try { try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return } if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
@@ -295,11 +476,25 @@ app.listen(PORT, () => {
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') } } catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
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
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const { mediaPath } = readSettings()
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
logger.error('organize', `scanMediaPath échoué: ${err}`)
)
// Auto-organise toutes les 30s // Auto-organise toutes les 30s
const autoOrganize = async () => { const autoOrganize = async () => {
try { try {
const { category } = readSettings() const { category } = readSettings()
await autoOrganizeAll(() => dispatchList(category ?? 'fankai')) await autoOrganizeAll(
() => dispatchList(category ?? 'fankai'),
(result) => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
}
}
)
} catch (err) { } catch (err) {
console.error('[organize] Erreur auto-organise:', err) console.error('[organize] Erreur auto-organise:', err)
} }
+105
View File
@@ -0,0 +1,105 @@
/**
* logger.ts
* =========
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
* Niveaux : debug | info | warn | error
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
*/
import fs from 'fs'
import path from 'path'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface LogEntry {
at : string // ISO timestamp
level : LogLevel
source : string // 'organize' | 'api' | 'scraper' | ...
msg : string
meta? : Record<string, any>
}
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const IS_PROD = process.env.NODE_ENV === 'production'
// ── Écriture ──────────────────────────────────────────────────
function writeLine(entry: LogEntry) {
try {
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
// Rotation si trop grand
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
const lines = content.split('\n').filter(Boolean)
if (lines.length > MAX_LINES) {
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
}
} catch {}
}
// ── API publique ──────────────────────────────────────────────
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
// Console
const prefix = `[${source}]`
if (level === 'error') console.error(prefix, msg, meta ?? '')
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
else if (level === 'info') console.log(prefix, msg, meta ?? '')
// Disque — pas les debug en prod
if (level === 'debug' && IS_PROD) return
writeLine(entry)
}
export const logger = {
debug: (source: string, msg: string, meta?: Record<string, any>) => log('debug', source, msg, meta),
info : (source: string, msg: string, meta?: Record<string, any>) => log('info', source, msg, meta),
warn : (source: string, msg: string, meta?: Record<string, any>) => log('warn', source, msg, meta),
error: (source: string, msg: string, meta?: Record<string, any>) => log('error', source, msg, meta),
}
// ── Lecture pour l'API ────────────────────────────────────────
export interface LogsReadOptions {
limit? : number // nb de lignes (défaut 100)
level? : LogLevel | 'all' // filtre niveau
source? : string // filtre source
}
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
try {
if (!fs.existsSync(LOGS_PATH)) return []
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
let lines = content.split('\n').filter(Boolean)
// Parse
let entries: LogEntry[] = []
for (const line of lines) {
try { entries.push(JSON.parse(line)) } catch {}
}
// Filtres
if (opts.level && opts.level !== 'all')
entries = entries.filter(e => e.level === opts.level)
if (opts.source)
entries = entries.filter(e => e.source === opts.source)
// Plus récents en premier
entries.reverse()
// Limite
const limit = opts.limit ?? 100
return entries.slice(0, limit)
} catch { return [] }
}
export function clearLogs(): void {
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
}
export function logsFileSize(): number {
try { return fs.statSync(LOGS_PATH).size } catch { return 0 }
}
+263
View File
@@ -0,0 +1,263 @@
/**
* organize-worker.ts
* ==================
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
* Messages envoyés (parentPort.postMessage) :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
*/
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string } {
try {
const p = path.join(process.cwd(), 'data', 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
}
// ─── Torrent lookup ───────────────────────────────────────────
function findTorrentByHash(hash: string): any | null {
try {
if (!fs.existsSync(TORRENTS_PATH)) return null
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
} catch { return null }
}
// ─── Dossiers exclus (bonus, musique, images) ────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
'images', 'image', 'scans', 'scan', 'soundtrack', 'music',
])
function isInExcludedFolder(filePath: string[]): boolean {
for (const folder of filePath.slice(0, -1)) {
const f = folder.toLowerCase().trim()
if (EXCLUDED_FOLDERS.has(f)) return true
for (const excl of EXCLUDED_FOLDERS) {
if (f.startsWith(excl)) return true
}
}
return false
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string {
return `Saison ${String(n).padStart(3, '0')}`
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
return false
}
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string) {
const { mediaPath, completePath, organizeMode } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const torrent = findTorrentByHash(hash)
if (!torrent) {
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
throw new Error(`Torrent introuvable : ${hash}`)
}
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
// ── Cas fichier unique ────────────────────────────────────
if (torrentFiles.length === 0) {
log(`[organize] torrent_files vide → tentative fichier unique`)
const candidates = [path.join(savePath, name), path.join(completePath, name)]
let src: string | null = null
for (const c of candidates) {
log(`[organize] candidate: ${c} → ${fs.existsSync(c) ? '✓' : '✗'}`)
if (fs.existsSync(c)) { src = c; break }
}
if (!src) throw new Error(`Source introuvable pour "${name}"`)
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, name)
log(`[organize] dest: ${dest}`)
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
log(`[organize] ✓ ${organizeMode} OK`)
} else {
log(`[organize] → dest existe déjà, skip`)
}
markOrganized(hash, name)
return { ...result, total: 1, done: 1 }
}
// ── Map filename → season ─────────────────────────────────
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined)
filenameSeasonMap.set(ep.filename, ep.season_number)
}
result.total = torrentFiles.length
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path
// Skip les fichiers dans des dossiers bonus/musique/images
if (isInExcludedFolder(filePath)) {
log(`[organize] skip bonus: ${filename}`)
result.skipped++
continue
}
if (isOrganized(hash, filename)) { result.skipped++; continue }
const candidates = [
path.join(savePath, name, ...filePath.slice(0, -1), filename),
path.join(savePath, ...filePath.slice(0, -1), filename),
path.join(savePath, name, filename),
path.join(savePath, filename),
path.join(completePath, name, ...filePath.slice(0, -1), filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) { src = c; break }
}
if (!src) {
const tried = candidates.map(c => ` • ${c}`).join('\n')
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
result.errors.push({ file: filename, error: 'Source introuvable' })
continue
}
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
result.skipped++
continue
}
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const torrent = findTorrentByHash(t.hash)
if (!torrent) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
continue
}
const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename])
: !!organized[t.hash]?.[t.name]
if (allDone) continue
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
}
}
parentPort?.postMessage({ type: 'done' })
})
+185 -288
View File
@@ -1,312 +1,209 @@
import express from 'express' /**
import cookieParser from 'cookie-parser' * organize.ts
import fs from 'fs' * ===========
import path from 'path' * Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js' * Le worker gère tout le filesystem (copy, hardlink, move).
import { readSettings, writeSettings } from './settings.js' */
import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll } from './organize.js'
registerDriver(qbittorrentDriver) import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
const app = express() // Un seul worker à la fois
const PORT = 3001 let workerRunning = false
const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
app.use(express.json()) export interface OrganizeResult {
app.use(cookieParser()) total : number
skipped : number
// ── Static frontend (prod) ───────────────────────────────────── done : number
const PUBLIC_PATH = path.join(process.cwd(), 'public') errors : { file: string; error: string }[]
if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
} }
// ── Auth ─────────────────────────────────────────────────────── /**
app.get('/api/auth/status', authStatus) * Scan initial du mediaPath au démarrage.
app.post('/api/auth/setup', authSetup) * Parcourt récursivement le dossier Jellyfin et marque dans organized.json
app.post('/api/auth/login', authLogin) * tous les fichiers déjà présents qui correspondent à un torrent connu.
app.post('/api/auth/logout', authLogout) */
export async function scanMediaPath(
mediaPath : string,
torrentsPath: string,
organizedPath: string
): Promise<{ found: number; added: number }> {
const result = { found: 0, added: 0 }
// ── Settings ─────────────────────────────────────────────────── if (!fs.existsSync(mediaPath)) {
app.get('/api/settings', requireAuth, (_req, res) => { logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
res.json(readSettings()) return result
}) }
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
})
// ── Torrent clients ──────────────────────────────────────────── // Charger torrent_final.json
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => { let torrents: any[] = []
res.json(getAvailableClients())
})
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map((c: any) => sanitizeClient(c)))
})
app.post('/api/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
const ok = removeClient(String(req.params.uuid))
if (!ok) res.status(404).json({ error: 'Client introuvable' })
else res.json({ ok: true })
})
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config } = req.body
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
const driver = getDriver(type)
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
res.json(await driver.test(config))
})
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.test(client.config))
})
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type)
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
res.json(await driver.healthcheck(client.config))
})
// ── Helpers ────────────────────────────────────────────────────
function readTorrents(): any[] {
try { try {
if (!fs.existsSync(TORRENTS_PATH)) return [] if (!fs.existsSync(torrentsPath)) return result
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
} catch { return [] } } catch {
} logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
async function fankaiGet(endpoint: string): Promise<any> { return result
const res = await fetch(`${FANKAI_API}${endpoint}`) }
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json() // Construire un index filename → { hash, infohash }
} const filenameIndex = new Map<string, string>() // filename → infohash
function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>()
for (const t of torrents) { for (const t of torrents) {
if (!t.serie_id) continue const hash = t.infohash?.toLowerCase()
if (!map.has(t.serie_id)) map.set(t.serie_id, []) if (!hash) continue
map.get(t.serie_id)!.push(t) for (const f of t.torrent_files ?? []) {
if (f.filename) filenameIndex.set(f.filename, hash)
}
// Fichier unique (torrent_files vide) → on indexe par raw name
if ((t.torrent_files ?? []).length === 0 && t.raw) {
filenameIndex.set(t.raw, hash)
}
} }
return map
// Charger organized.json existant
let organized: Record<string, Record<string, string>> = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Parcourir récursivement mediaPath
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
catch { return }
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
result.found++
const hash = filenameIndex.get(entry.name)
if (!hash) continue
if (organized[hash]?.[entry.name]) continue // déjà connu
// Marquer comme organisé
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
result.added++
}
}
}
walk(mediaPath)
if (result.added > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
} else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
}
return result
} }
// ── Series ───────────────────────────────────────────────────── /**
app.get('/api/series', requireAuth, async (_req, res) => { * Lance l'auto-organisation dans un worker thread.
try { * listFn est appelée dans le thread principal (accès qBittorrent),
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) * puis les données sont passées au worker.
const byId = indexBySerie(torrents) */
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? []) export async function autoOrganizeAll(
res.json({ series: seriesList.map((serie: any) => ({ listFn : () => Promise<any[]>,
...serie, onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
torrent_count: byId.get(serie.id)?.length ?? 0, ): Promise<void> {
has_torrents : byId.has(serie.id) if (workerRunning) {
}))}) logger.debug('organize', 'Worker déjà en cours, skip')
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
app.get('/api/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
}))
const torrents = readTorrents().filter(t => t.serie_id === 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 episodeTorrentMap: Record<number, any> = {}
const seasonTorrentMap : Record<number, any> = {}
const integraleTorrents: any[] = []
const packEpisodesTorrents: any[] = []
for (const t of torrents) {
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
}
if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? []
if (rs.length > 1) {
packEpisodesTorrents.push(t)
} else {
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
}
} else if (t.type === 'pack_integrale') {
integraleTorrents.push(t)
} else if (t.type === 'pack_episodes') {
packEpisodesTorrents.push(t)
}
}
let promotedIntegrale: any = null
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
const rs: any[] = t.resolved_seasons ?? []
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
}
}
if (integraleTorrents.length > 0 || promotedIntegrale) {
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
if (promotedIntegrale) {
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
}
}
function packEpisodesLabel(t: any): string {
const eps: any[] = t.resolved_episodes ?? []
if (!eps.length) return t.raw
const bySeason = new Map<number, number[]>()
for (const ep of eps) {
const sn = ep.season_number ?? 0
if (!bySeason.has(sn)) bySeason.set(sn, [])
bySeason.get(sn)!.push(ep.episode_number)
}
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
const s = nums.sort((a, b) => a - b)
const label = sn === 0 ? 'S0' : `S${sn}`
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
}).join(' · ')
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
...season,
torrent: seasonTorrentMap[season.id] ?? null,
episodes: season.episodes.map((ep: any) => ({
...ep,
available: availableEpisodeIds.has(ep.id),
torrent : episodeTorrentMap[ep.id] ?? null
}))
}))
const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
]
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Download ───────────────────────────────────────────────────
app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.torrent_url ?? req.body.magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const results = await dispatchDownload(url)
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
// ── Downloads — liste torrents Fankai ──────────────────────────
app.get('/api/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai')
res.json(torrents)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Organize ───────────────────────────────────────────────────
app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) {
res.status(400).json({ error: 'hash, name et save_path requis' })
return return
} }
let torrents: any[]
try { try {
const result = await organizeTorrent(hash, name, save_path) torrents = await listFn()
res.json(result)
} catch (err) { } catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) logger.error('organize', "Impossible de récupérer la liste des torrents: " + (err instanceof Error ? err.message : String(err)))
return
} }
})
// ── Torrents status ──────────────────────────────────────────── if (torrents.length === 0) return
app.get('/api/torrents/status', requireAuth, (_req, res) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
const data = JSON.parse(raw)
const count = Array.isArray(data) ? data.length : 0
res.json({ exists: true, count, empty: count === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
// ── Update torrent_final.json ────────────────────────────────── const seedingCount = torrents.filter(t => t.state === 'seeding').length
app.post('/api/update', requireAuth, async (_req, res) => { if (seedingCount === 0) return
try {
const response = await fetch(GITHUB_RAW_URL)
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
const data = await response.json() as unknown[]
if (!Array.isArray(data)) throw new Error('Format invalide')
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
res.json({ ok: true, count: data.length })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── SPA fallback (prod) — doit être après toutes les routes API ─ logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
if (fs.existsSync(PUBLIC_PATH)) { workerRunning = true
app.get('*path', (_req, res) => {
res.sendFile(path.join(PUBLIC_PATH, 'index.html')) const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
} else if (msg.type === 'result') {
logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
onResult?.({
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles : msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
workerRunning = false
worker.terminate()
}
}) })
worker.on('error', (err) => {
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
workerRunning = false
})
worker.on('exit', (code) => {
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
workerRunning = false
})
// Envoyer les torrents au worker
worker.postMessage({ type: 'run', torrents })
} }
// ── Start ────────────────────────────────────────────────────── /**
app.listen(PORT, () => { * Organisation manuelle d'un torrent (appelée depuis l'API /organize).
try { * Lance aussi dans un worker thread.
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) */
const count = Array.isArray(data) ? data.length : 0 export async function organizeTorrent(
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide') hash : string,
else console.log(`[fankarr] ${count} torrents chargés`) name : string,
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') } savePath: string
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) ): Promise<OrganizeResult> {
return new Promise((resolve, reject) => {
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
// Auto-organise toutes les 30s // On envoie un seul torrent "fictif seeding"
const autoOrganize = async () => { const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
try { const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
const { category } = readSettings()
await autoOrganizeAll(() => dispatchList(category ?? 'fankai')) worker.on('message', (msg: any) => {
} catch (err) { if (msg.type === 'log') {
console.error('[organize] Erreur auto-organise:', err) logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
} } else if (msg.type === 'result') {
} result.total = msg.total
// Premier run 30s après démarrage, puis toutes les 30s result.skipped = msg.skipped
setTimeout(() => { result.done = msg.done
autoOrganize() result.errors = msg.errors
setInterval(autoOrganize, 30_000) } else if (msg.type === 'done') {
}, 30_000) worker.terminate()
}) resolve(result)
}
})
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
})
}
+44
View File
@@ -0,0 +1,44 @@
/**
* secret.ts
* =========
* Génère et persiste le JWT secret dans data/secret.key
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
*/
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
}
function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET
}
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret
}
// 3. Génération + sauvegarde
const secret = generateSecret()
try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
} catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err)
}
return secret
}
export const JWT_SECRET: string = loadOrCreateSecret()
+4
View File
@@ -25,6 +25,10 @@ const router = createRouter({
{ {
path: '/settings', path: '/settings',
component: () => import('@/views/SettingsView.vue') component: () => import('@/views/SettingsView.vue')
},
{
path: '/logs',
component: () => import('@/views/LogsView.vue')
} }
], ],
scrollBehavior(to, _from, savedPosition) { scrollBehavior(to, _from, savedPosition) {
+96 -11
View File
@@ -8,14 +8,31 @@
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span> <span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<router-link to="/downloads" <!-- Icône logs -->
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative"> <router-link to="/logs"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg> </svg>
<!-- Badge si téléchargements actifs --> </router-link>
<router-link to="/downloads"
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<!-- Badge compteur -->
<span v-if="activeDownloads > 0" <span v-if="activeDownloads > 0"
class="absolute top-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/> class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 bg-orange-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{{ activeDownloads }}
</span>
</router-link> </router-link>
<router-link <router-link
to="/settings" to="/settings"
@@ -120,6 +137,27 @@
{{ serie.torrent_count }} {{ serie.torrent_count }}
</div> </div>
<!-- Badge état téléchargement -->
<div v-if="serie.download_state !== 'none'"
class="absolute bottom-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none backdrop-blur-sm"
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
serie.download_state === 'downloading' ? 'bg-blue-500/90 text-white' : ''">
<!-- Icône -->
<svg v-if="serie.download_state === 'complete'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
<svg v-else-if="serie.download_state === 'downloading'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" class="animate-pulse">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<svg v-else width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
</div>
<!-- Overlay hover --> <!-- Overlay hover -->
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"> <div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span> <span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
@@ -148,30 +186,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series' import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
defineOptions({ name: 'CatalogView' })
const store = useSeriesStore() const store = useSeriesStore()
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const { add: toast } = useToast()
const search = ref('') const search = ref('')
const activeFilter = ref('all') const activeFilter = ref('all')
const activeDownloads = ref(0)
const filters = [ const filters = [
{ label: 'Toutes', value: 'all' }, { label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' }, { label: 'Disponibles', value: 'available' },
{ label: 'Téléchargés', value: 'complete' },
{ label: 'En cours', value: 'downloading' },
{ label: 'Sans torrent', value: 'unavailable' }, { label: 'Sans torrent', value: 'unavailable' },
] ]
const filtered = computed(() => { const filtered = computed(() => {
let list = store.series let list = store.series
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents) if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents) if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
if (search.value.trim()) { if (search.value.trim()) {
const q = search.value.toLowerCase() const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q)) list = list.filter(s => s.title.toLowerCase().includes(q))
@@ -184,7 +227,49 @@ async function handleLogout() {
router.push('/auth') router.push('/auth')
} }
// ── Polling downloads actifs + notifications organisation ──────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeDownloads.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {}
}
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
} catch {}
}
onMounted(() => { onMounted(() => {
if (store.series.length === 0) store.fetchSeries() if (store.series.length === 0) store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
fetchActiveDownloads()
fetchOrganizeNotifs()
}, 10000)
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
}) })
</script> </script>
+38 -11
View File
@@ -103,16 +103,44 @@
</span> </span>
</div> </div>
<!-- Bouton organiser si complété --> <!-- Badge organisation -->
<button v-if="t.state === 'seeding'" <div v-if="t.state === 'seeding'" class="flex items-center gap-2">
@click="organize(t)"
:disabled="organizing[t.hash]" <!-- Badge erreurs avec tooltip -->
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer" <div v-if="t.errorFiles?.length > 0" class="relative group/err">
:class="organized[t.hash] <span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
? 'border-green-500/40 text-green-500' {{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'"> </span>
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }} <!-- Tooltip -->
</button> <div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
<p class="text-white truncate">{{ e.file }}</p>
<p class="text-[#5a7a94]">{{ e.error }}</p>
</div>
</div>
</div>
</div>
<!-- Badge statut organisation -->
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-[#1e2d3d] text-[#5a7a94]'">
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'NON ORGANISÉ' }}
</span>
<!-- Bouton organiser si pas encore fait -->
<button v-if="t.organizeState !== 'done'"
@click="organize(t)"
:disabled="organizing[t.hash]"
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
{{ organizing[t.hash] ? '...' : 'ORGANISER' }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -130,7 +158,6 @@ const loading = ref(true)
const polling = ref(true) const polling = ref(true)
const noClients = ref(false) const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({}) const organizing = ref<Record<string, boolean>>({})
const organized = ref<Record<string, boolean>>({})
let pollInterval: ReturnType<typeof setInterval> | null = null let pollInterval: ReturnType<typeof setInterval> | null = null
+209
View File
@@ -0,0 +1,209 @@
<template>
<div class="max-w-5xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<button @click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Logs</h1>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ formatSize(fileSize) }}</span>
<button @click="confirmClear"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-red-500 hover:text-red-500 transition-colors cursor-pointer">
EFFACER
</button>
<button @click="load"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
↺ RAFRAÎCHIR
</button>
</div>
</div>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-3 mb-5">
<!-- Niveau -->
<div class="flex gap-1">
<button v-for="l in levels" :key="l.value" @click="filterLevel = l.value; load()"
class="px-3 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="filterLevel === l.value
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ l.label }}
</button>
</div>
<!-- Source -->
<select v-model="filterSource" @change="load()"
class="bg-[#0d1219] border border-[#1e2d3d] text-[#5a7a94] px-3 py-1 text-[10px] tracking-widest outline-none cursor-pointer">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<!-- Limite / tout afficher -->
<div class="flex items-center gap-2 ml-auto">
<span class="text-[10px] text-[#5a7a94] tracking-widest">AFFICHER</span>
<button v-for="n in [100, 500, 2000]" :key="n" @click="limit = n; load()"
class="px-2 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
:class="limit === n
? 'border-[#e8513a] text-[#e8513a]'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
{{ n === 2000 ? 'TOUT' : n }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-3 min-h-48 text-[#5a7a94]">
<div class="w-4 h-4 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
<span class="text-sm">Chargement...</span>
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center min-h-48 text-[#5a7a94] text-sm">
Aucun log
</div>
<!-- Table logs -->
<div v-else class="bg-[#0d1219] border border-[#1e2d3d] overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-[#1e2d3d]">
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-4 py-2.5 w-40">HORODATAGE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-16">NIVEAU</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-24">SOURCE</th>
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5">MESSAGE</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, i) in entries" :key="i"
class="border-b border-[#1e2d3d]/50 hover:bg-white/[0.02] transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''">
<td class="px-4 py-2 text-[#5a7a94] whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="text-[10px] tracking-widest px-1.5 py-0.5 rounded"
:class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-[#5a7a94] whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-[#c8d8e8] break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-[#5a7a94] ml-2">
{{ JSON.stringify(entry.meta) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer count -->
<div class="px-4 py-2.5 border-t border-[#1e2d3d] flex items-center justify-between">
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ entries.length }} entrées affichées</span>
<span class="text-[10px] text-[#5a7a94]">{{ formatSize(fileSize) }} sur disque</span>
</div>
</div>
<!-- Confirm clear -->
<div v-if="showConfirm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 max-w-sm w-full">
<h3 class="text-sm font-bold text-white mb-2">Effacer les logs ?</h3>
<p class="text-[11px] text-[#5a7a94] mb-6">Cette action est irréversible.</p>
<div class="flex gap-3">
<button @click="doClear"
class="bg-red-500 text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
EFFACER
</button>
<button @click="showConfirm = false"
class="border border-[#1e2d3d] text-[#5a7a94] px-5 py-2 text-xs tracking-widest hover:border-white/30 transition-colors cursor-pointer">
ANNULER
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
const filterLevel = ref('all')
const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'TOUS', value: 'all' },
{ label: 'INFO', value: 'info' },
{ label: 'WARN', value: 'warn' },
{ label: 'ERROR', value: 'error' },
{ label: 'DEBUG', value: 'debug' },
]
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit : String(limit.value),
level : filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {})
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
} finally {
loading.value = false
}
}
function confirmClear() { showConfirm.value = true }
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
await load()
}
function formatDate(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function levelClass(level: string): string {
return {
debug: 'bg-[#1e2d3d] text-[#5a7a94]',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
}[level] ?? 'bg-[#1e2d3d] text-[#5a7a94]'
}
onMounted(load)
</script>
+21 -6
View File
@@ -27,14 +27,14 @@
<!-- Hero --> <!-- Hero -->
<div class="relative"> <div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none"> <div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" alt="fanart image"/> <img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" /> <div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
</div> </div>
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8"> <div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
<!-- Poster --> <!-- Poster -->
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block"> <div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" alt="poster image"/> <img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700"> <div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/> <rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
@@ -108,6 +108,15 @@
<p class="text-xs text-zinc-600 mt-0.5"> <p class="text-xs text-zinc-600 mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }} {{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo · {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="season.organized_state === 'complete'
? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'">
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
</span>
</p> </p>
</div> </div>
</div> </div>
@@ -135,7 +144,7 @@
> >
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block"> <div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" alt="thumb image"/> <img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono"> <div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }} {{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div> </div>
@@ -156,10 +165,16 @@
</div> </div>
<!-- Bouton épisode (type episode uniquement) --> <!-- Bouton épisode (type episode uniquement) -->
<div class="shrink-0"> <div class="shrink-0 flex items-center gap-2">
<!-- Badge organisé -->
<span v-if="ep.organized"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé
</span>
<!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) --> <!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) -->
<span <span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border mr-2" class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available :class="ep.available
? 'bg-green-500/10 text-green-400 border-green-500/20' ? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'" : 'bg-white/5 text-zinc-600 border-white/8'"
@@ -258,7 +273,7 @@ async function download(key: string, torrent_url: string | null, magnet: string
downloading.value = key downloading.value = key
const result = await store.download(torrent_url, magnet) const result = await store.download(torrent_url, magnet)
downloading.value = null downloading.value = null
if (result.success) showToast('success', 'Ajouté à qBittorrent !') if (result.success) showToast('success', 'Téléchargement lancé')
else showToast('error', result.error ?? 'Erreur inconnue') else showToast('error', result.error ?? 'Erreur inconnue')
} }
+30 -13
View File
@@ -87,20 +87,34 @@
<!-- Dossier complétion --> <!-- Dossier complétion -->
<div> <div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label> <div class="flex items-center gap-2 mb-1.5">
<input v-model="form.completePath" <label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors" <span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<input v-model="form.completePath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/downloads/complete"/> placeholder="/downloads/complete"/>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent (monté dans Docker)</p> <p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier de complétion de qBittorrent</span>
</p>
</div> </div>
<!-- Dossier Jellyfin --> <!-- Dossier Jellyfin -->
<div> <div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label> <div class="flex items-center gap-2 mb-1.5">
<input v-model="form.mediaPath" <label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors" <span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<input v-model="form.mediaPath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/media/Kai"/> placeholder="/media/Kai"/>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p> <p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier racine de la bibliothèque Fankai dans Jellyfin</span>
</p>
</div> </div>
<!-- Mode organisation --> <!-- Mode organisation -->
@@ -215,8 +229,9 @@ const form = ref({
organizeMode: 'hardlink' as 'hardlink' | 'move', organizeMode: 'hardlink' as 'hardlink' | 'move',
}) })
const saving = ref(false) const saving = ref(false)
const updating = ref(false) const updating = ref(false)
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true }) const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([]) const clients = ref<any[]>([])
@@ -244,18 +259,20 @@ function clientLabel(type: string): string {
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
onMounted(async () => { onMounted(async () => {
const [settingsRes, statusRes, clientsRes, availRes] = await Promise.all([ const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }), fetch('/api/settings', { credentials: 'include' }),
fetch('/api/torrents/status', { credentials: 'include' }), fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }), fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }), fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
]) ])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json()) if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json() if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json() if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json() if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) refreshHealth(c.uuid) for (const c of clients.value) await refreshHealth(c.uuid)
}) })
// ── Healthcheck ──────────────────────────────────────────────── // ── Healthcheck ────────────────────────────────────────────────
@@ -345,7 +362,7 @@ async function saveClient() {
if (res.ok) { if (res.ok) {
const client = await res.json() const client = await res.json()
clients.value.push(client) clients.value.push(client)
refreshHealth(client.uuid) await refreshHealth(client.uuid)
closeModal() closeModal()
toast('Client enregistré ✓', 'success') toast('Client enregistré ✓', 'success')
} else { } else {