diff --git a/.env.example b/.env.example
index 9c8a065..6ee95b3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,22 +1,16 @@
-# FanKarr — Configuration (binaire bun)
-# Copier ce fichier en .env dans le même répertoire que le binaire fankarr
+# FanKarr : configuration du binaire Bun
+# Copier ce fichier en .env dans le même dossier que le binaire fankarr
# ── Authentification ──────────────────────────────────────────────────────────
-# Clé secrète JWT (générée automatiquement si absente, mais recommandé de la fixer)
+# Clé secrète JWT (générée automatiquement si absente ; il est conseillé de la fixer)
# JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
-# Durée de validité du token de connexion
-# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration
+# Durée de validité de la session
+# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
# AUTH_TOKEN_EXPIRY=30d
# ── Serveur ───────────────────────────────────────────────────────────────────
# Port d'écoute (défaut : 9898)
# PORT=9898
-
-# ── Données ───────────────────────────────────────────────────────────────────
-
-# Répertoire de données (défaut : ./config/ à côté du binaire)
-# Utile pour pointer vers un volume Docker ou un répertoire partagé
-# DATA_DIR=/opt/fankarr/data
diff --git a/.github/images/catalog.png b/.github/images/catalog.png
deleted file mode 100644
index eae2d61..0000000
Binary files a/.github/images/catalog.png and /dev/null differ
diff --git a/.github/images/catalog.webp b/.github/images/catalog.webp
new file mode 100644
index 0000000..01878a7
Binary files /dev/null and b/.github/images/catalog.webp differ
diff --git a/.github/images/dashboard.webp b/.github/images/dashboard.webp
new file mode 100644
index 0000000..8921120
Binary files /dev/null and b/.github/images/dashboard.webp differ
diff --git a/.github/images/downloads.png b/.github/images/downloads.png
deleted file mode 100644
index abdc596..0000000
Binary files a/.github/images/downloads.png and /dev/null differ
diff --git a/.github/images/downloads.webp b/.github/images/downloads.webp
new file mode 100644
index 0000000..4aab82e
Binary files /dev/null and b/.github/images/downloads.webp differ
diff --git a/.github/images/logs.png b/.github/images/logs.png
deleted file mode 100644
index b646b6b..0000000
Binary files a/.github/images/logs.png and /dev/null differ
diff --git a/.github/images/logs.webp b/.github/images/logs.webp
new file mode 100644
index 0000000..0f3ac69
Binary files /dev/null and b/.github/images/logs.webp differ
diff --git a/.github/images/mobile-activity.webp b/.github/images/mobile-activity.webp
new file mode 100644
index 0000000..8bf24b2
Binary files /dev/null and b/.github/images/mobile-activity.webp differ
diff --git a/.github/images/mobile-dashboard.webp b/.github/images/mobile-dashboard.webp
new file mode 100644
index 0000000..3e2dcd1
Binary files /dev/null and b/.github/images/mobile-dashboard.webp differ
diff --git a/.github/images/mobile-serie.webp b/.github/images/mobile-serie.webp
new file mode 100644
index 0000000..ebe6bdd
Binary files /dev/null and b/.github/images/mobile-serie.webp differ
diff --git a/.github/images/serie.png b/.github/images/serie.png
deleted file mode 100644
index 5cd18d2..0000000
Binary files a/.github/images/serie.png and /dev/null differ
diff --git a/.github/images/serie.webp b/.github/images/serie.webp
new file mode 100644
index 0000000..af6c821
Binary files /dev/null and b/.github/images/serie.webp differ
diff --git a/.github/images/settings.png b/.github/images/settings.png
deleted file mode 100644
index 60f4ff4..0000000
Binary files a/.github/images/settings.png and /dev/null differ
diff --git a/.github/images/settings.webp b/.github/images/settings.webp
new file mode 100644
index 0000000..cf232a8
Binary files /dev/null and b/.github/images/settings.webp differ
diff --git a/.github/images/setup.webp b/.github/images/setup.webp
new file mode 100644
index 0000000..75f7819
Binary files /dev/null and b/.github/images/setup.webp differ
diff --git a/Dockerfile b/Dockerfile
index 66d95c1..50dfbbf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,6 +28,6 @@ RUN chmod +x /entrypoint.sh
RUN mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt
-EXPOSE 3001
+EXPOSE 9898
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
diff --git a/README.md b/README.md
index 046504f..0e9dbcf 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@

**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 et Sonarr, pour les éditions Kai et Yabai

@@ -21,77 +21,91 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
## Aperçu
-
-
+
-> Vue catalogue : toutes les séries Fankai. Une barre en bas de chaque affiche indique l'état — disponible, en cours de téléchargement ou déjà importé dans votre médiathèque.
+> Accueil : le dernier import, les séries ajoutées récemment et les chiffres de la médiathèque. L'administrateur y retrouve ce qui attend une action : demandes, imports en erreur, dossiers à renommer.
---
-
-
+
-> Vue série : épisodes avec badge de langue (VOSTFR / MULTI), progression par fichier, et dropdown de sélection quand plusieurs sources sont disponibles.
+> Médiathèque : tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement, dossier à renommer, nouveauté), avec des filtres, un tri et plusieurs tailles d'affiches.
---
-
-
+
-> Vue téléchargements : suivi en temps réel, état d'organisation par torrent et détail des erreurs au survol.
+> Fiche d'une série : l'import saison par saison et épisode par épisode, avec le téléchargement d'un épisode, d'une saison ou de toute la série, l'import manuel et la surveillance des nouveaux épisodes.
+
+---
+
+
+
+> Activité : un torrent par ligne, du téléchargement à l'import, avec le détail des erreurs et un bouton pour relancer l'import.
+
+---
+
+
+
+
+
+
+
+> Sur mobile : l'interface s'adapte au téléphone, avec une barre d'onglets en bas de l'écran.
---
## Fonctionnalités
-### 📥 Téléchargement
-- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité
-- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale
-- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
-- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
-- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
-- **RSS Sync** — surveillance automatique des nouvelles sorties toutes les 6 heures pour les séries abonnées, téléchargement sans intervention
+### Téléchargement
+- Tout le catalogue Fankai, avec les affiches et l'état de chaque série
+- Envoi au client torrent par épisode, par saison ou en intégrale
+- Choix de la source quand plusieurs torrents existent (qualité, pack)
+- Bouton « Tout télécharger » : l'intégrale choisie, complétée par les packs des épisodes qu'elle ne contient pas
+- Progression par épisode, même pour un fichier contenu dans un pack (si le client torrent l'indique)
+- Surveillance : les nouveaux épisodes des séries surveillées sont envoyés au client (vérification toutes les 6 heures)
-### 🗂️ Organisation
-- **Import automatique** des fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
-- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
-- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
-- **Désimport automatique** optionnel quand un fichier est supprimé du disque
-- **Suppression du dossier série** automatique lors d'un désimport complet avec suppression des fichiers
+### Organisation
+- Import automatique des téléchargements terminés dans la médiathèque (vérification toutes les 5 minutes)
+- Trois modes d'import : Hardlink (recommandé), Copier ou Déplacer. Avec un hardlink, le client torrent continue de partager le fichier.
+- Analyse de la médiathèque : les épisodes déjà présents sur le disque sont reconnus sans être déplacés
+- En option, les épisodes dont le fichier a disparu sont retirés de la médiathèque
+- Quand vous retirez une série de la médiathèque en supprimant ses fichiers, son dossier est effacé et ses torrents sont retirés du client
-### 📋 Demandes
-- **Système de demandes** — les utilisateurs peuvent demander des séries, saisons ou épisodes spécifiques depuis l'interface ou le plugin Jellyfin
-- **Workflow admin** — approbation / refus avec message / marquage disponible depuis la page dédiée
-- **Auto-téléchargement** configurable par utilisateur — les demandes sont approuvées et lancées automatiquement pour les utilisateurs autorisés
-- **Suppression en masse** pour les admins (bouton "Tout supprimer")
-- **Nettoyage automatique** — les demandes marquées "disponible" sont supprimées lors du désimport de la série concernée
+### Demandes
+- Les invités demandent une série, une saison ou des épisodes, depuis FanKarr ou depuis le plugin Jellyfin
+- Les administrateurs approuvent, refusent (avec un message) ou marquent une demande comme disponible
+- Approbation automatique, pour tous les invités ou certains seulement : la demande est approuvée et le torrent part aussitôt au client
+- Bouton « Tout supprimer » pour vider la liste
+- Une demande passe à « disponible » quand des épisodes de la série sont importés, et les demandes disponibles sont supprimées quand la série est retirée de la médiathèque
-### 🏷️ Métadonnées & Affichage
-- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
-- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
-- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
+### Métadonnées et affichage
+- Badges de langue VOSTFR et MULTI sur chaque épisode, détectés automatiquement
+- Fichiers NFO et images en option, pour Infuse ou les lecteurs sans agent Fankai
+- Plex : connexion au compte, installation de l'agent de métadonnées Fankai et création de la bibliothèque (Plex Media Server 1.43 ou plus récent pour l'agent)
+- Jellyfin, Emby et Kodi : métadonnées Fankai avec le [plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) ou l'add-on [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) (Kodi 20 ou plus récent)
-### 👥 Utilisateurs & Jellyfin
-- **Multi-utilisateurs** avec rôles (`admin` / `user`) et système d'invitations
-- **SSO Jellyfin** — les utilisateurs se connectent avec leur compte Jellyfin, aucun mot de passe FanKarr requis
-- **Synchronisation Jellyfin** — création automatique des comptes FanKarr pour les utilisateurs Jellyfin, toutes les heures en arrière-plan ou manuellement depuis les paramètres
-- **Token API personnel** — chaque utilisateur dispose d'un token pour accéder à l'API publique
+### Utilisateurs et Jellyfin
+- Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation
+- Synchronisation Jellyfin : un compte FanKarr est créé pour chaque utilisateur Jellyfin, toutes les heures ou à la demande
+- Connexion depuis le plugin Jellyfin avec le compte Jellyfin
+- Un jeton d'API personnel par utilisateur pour l'API publique
-### ⚙️ Système
-- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
-- **Authentification** par mot de passe avec session JWT
-- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid
-- **Compatible Docker / Runtipi / Binaire autonome**
+### Système
+- Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface
+- Authentification par mot de passe, session JWT
+- Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs.
+- Installation avec Docker, Runtipi ou un binaire autonome
---
-## Données torrent — Scraper
+## Données des torrents (scraper)
-Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
+Les données du catalogue viennent du dépôt [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
-Ce repo contient un pipeline Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, les croise avec l'API metadata de Fankai pour identifier chaque épisode, et publie le résultat automatiquement **toutes les 6 heures** via GitHub Actions. Les données sont organisées par série dans un dossier `/serie` et des fichiers d'index à la racine du repo.
+Ce dépôt récupère les torrents Fankai sur les trackers publics, les associe aux épisodes grâce à l'API Fankai et publie le résultat toutes les 6 heures avec GitHub Actions : un fichier par série dans le dossier `series/`, et des fichiers d'index à la racine (`available.json`, `infohash_map.json`).
-FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
+FanKarr lit ces données au démarrage et les garde une heure en cache. Pour les recharger tout de suite, utilisez **Synchroniser maintenant** dans **Paramètres › Catalogue Fankai**. Les séries qui ne sont pas encore dans le scraper sont complétées par l'API Fankai, sans torrents. La collecte des torrents ne se fait pas sur votre serveur.
---
@@ -99,7 +113,7 @@ FanKarr récupère ces données au démarrage et via le bouton **Mettre à jour*
### Docker Compose (recommandé)
-**Prérequis** : Docker + Docker Compose, un client torrent accessible en réseau.
+**Prérequis** : Docker et Docker Compose, et un client torrent joignable depuis FanKarr.
```yaml
services:
@@ -109,10 +123,10 @@ services:
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- - TZ=Europe/Paris # Votre timezone
+ - TZ=Europe/Paris # Fuseau horaire
volumes:
- - ./config:/config # Config, logs, base de données
- - /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
+ - ./config:/config # Configuration, journaux et données
+ - /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
ports:
- 9898:9898
restart: unless-stopped
@@ -122,86 +136,96 @@ services:
docker compose up -d
```
-> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme Radarr et Sonarr le font.
+> **Hardlinks** : le dossier des téléchargements terminés et la médiathèque doivent être sur le même système de fichiers. Montez un seul volume parent qui contient les deux (ex. `/votre/chemin:/media`), puis choisissez vos dossiers à l'intérieur, comme avec Radarr et Sonarr.
>
-> Plusieurs volumes sont possibles mais les hardlinks ne fonctionneront pas entre deux volumes différents — FanKarr basculera automatiquement en copie.
+> Vous pouvez monter plusieurs volumes, mais les hardlinks ne fonctionnent pas d'un volume à l'autre ; FanKarr copie alors les fichiers.
+>
+> Si le client torrent tourne dans un autre conteneur ou sur une autre machine, renseignez dans ses réglages le chemin distant (le dossier tel que le client le voit) et le chemin local (le même dossier tel que FanKarr le voit).
---
-### 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 de votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
-| Système | Archive |
-| -------------------------------- | -------------------------------- |
-| Windows x64 | `fankarr-windows-x64.zip` |
-| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` |
-| Linux x64 | `fankarr-linux-x64.zip` |
-| macOS (Apple Silicon) | `fankarr-macos.zip` |
+| Système | Archive |
+| ---------------------------------------- | ---------------------------------- |
+| Windows x64 | `fankarr-windows-x64.zip` |
+| Windows x64, anciens processeurs | `fankarr-windows-x64-legacy.zip` |
+| Linux x64 | `fankarr-linux-x64.zip` |
+| Linux ARM64 | `fankarr-linux-arm64.zip` |
+| macOS (Apple Silicon) | `fankarr-macos.zip` |
-Extrayez l'archive — tous les fichiers doivent rester ensemble dans le même répertoire :
+Extrayez l'archive dans un dossier. Tous les fichiers doivent rester ensemble :
```
-fankarr-windows-x64/
-├── fankarr.exe ← lancer ce fichier
-├── organize-worker.js ← worker d'organisation (ne pas déplacer)
-├── .env ← configuration optionnelle (voir Variables d'environnement)
-└── public/ ← assets frontend (ne pas déplacer)
+fankarr/
+├── fankarr.exe # à lancer (fankarr sous Linux et macOS)
+├── organize-worker.js # tâche d'import, à garder à côté du binaire
+├── public/ # fichiers de l'interface, à garder à côté du binaire
+├── version.txt
+└── .env # facultatif, à créer (voir Variables d'environnement)
```
-> Le fichier `.env` permet de configurer le port, la durée des sessions, etc. sans passer par des variables d'environnement système. Il est chargé automatiquement au démarrage si présent à côté du binaire.
+> Le fichier `.env` sert à régler le port, la durée des sessions, etc. sans passer par les variables d'environnement du système. S'il est présent à côté du binaire, il est chargé au démarrage. Le fichier [`.env.example`](.env.example) du dépôt sert de modèle.
-**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
+**Linux et macOS** : dans l'archive, le binaire est rangé dans un sous-dossier `binaries/`. Placez-le à côté de `public/` et rendez-le exécutable avant le premier lancement :
```bash
-wget https://github.com/Masutayunikon/FanKarr/releases/download/v3.14.6/fankarr-linux-arm64.zip
-unzip fankarr-linux-arm64.zip -d fankarr
+wget https://github.com/masutayunikon/fankarr/releases/latest/download/fankarr-linux-x64.zip
+unzip fankarr-linux-x64.zip -d fankarr
cd fankarr
-################
-# if the "fankarr" files in inside the binaries directory use this command
-# mv ./binaries/fankarr .
-##################
+# Le binaire est extrait dans binaries/ : le placer à côté de public/ et de organize-worker.js
+mv binaries/fankarr .
chmod +x fankarr
./fankarr
```
-FanKarr sera accessible sur `http://localhost:9898`. La configuration, les logs et la base de données sont sauvegardés dans un dossier `config/` créé automatiquement à côté du binaire.
+Pour Linux ARM64 ou macOS, remplacez le nom de l'archive.
+
+FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les journaux et les données sont enregistrés dans un dossier `config/`, créé automatiquement à côté du binaire.
---
### Variables d'environnement
-| Variable | Défaut | Description |
-| ------------------ | ------------- | ------------------------------------------------------------------------------- |
-| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
-| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
-| `TZ` | — | Timezone (ex: `Europe/Paris`) |
-| `PORT` | `9898` | Port d'écoute du serveur |
-| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
-| `AUTH_TOKEN_EXPIRY`| `30d` | Durée de validité du token de connexion (ex: `7d`, `1y`, `never`) |
-| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) |
-| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
+| Variable | Défaut | Description |
+| ------------------- | ------------- | ------------------------------------------------------------------------------------ |
+| `PUID` | `1000` | UID propriétaire des fichiers de `/config` (Docker) |
+| `PGID` | `1000` | GID propriétaire des fichiers de `/config` (Docker) |
+| `TZ` | non défini | Fuseau horaire (ex. `Europe/Paris`) |
+| `PORT` | `9898` | Port d'écoute du serveur |
+| `JWT_SECRET` | généré | Secret des sessions, généré dans `secret.key` (dossier de configuration) si absent |
+| `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
+| `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
+
+Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
---
-### Premier démarrage
+### Premier lancement
-1. Ouvrir `http://localhost:9898`
-2. Créer un mot de passe à la première connexion
-3. Aller dans **Paramètres** → configurer votre client torrent
-4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque**
-5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy`
-6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque
-7. Retourner au catalogue et télécharger
+1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
+2. Créez le compte administrateur.
+3. Suivez l'assistant de configuration :
+ - dossiers : téléchargements terminés, médiathèque et mode d'import (Hardlink, recommandé, Copier ou Déplacer) ;
+ - client torrent ;
+ - options d'import : import automatique, nommage des dossiers, fichiers NFO ;
+ - serveur multimédia, Jellyfin ou Plex (facultatif) ;
+ - catalogue : synchronisation des séries Fankai, puis analyse de la médiathèque si elle contient déjà des épisodes.
+4. Terminez avec la visite guidée, qui présente les écrans principaux, ou passez-la.
-
-
+
+
+Tous ces réglages restent modifiables dans les paramètres, et l'assistant peut être relancé.
+
+
---
## Organisation des fichiers
-FanKarr organise les fichiers terminés vers la structure attendue par Jellyfin / Plex :
+FanKarr range les fichiers terminés selon la structure attendue par Jellyfin et Plex :
```
Black Lagoon Henshū/
@@ -218,59 +242,62 @@ Black Lagoon Henshū/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv
```
-Le mode **hardlink** est recommandé si vos dossiers de téléchargement et de médiathèque sont sur le même filesystem — les fichiers ne sont pas déplacés et le client torrent continue de seeder sans interruption.
+Une option permet de nommer les dossiers de saison « Season 01 » plutôt que « Saison 1 ».
-L'organisation se déclenche :
-- **Automatiquement** dès la fin d'un téléchargement, et toutes les 5 minutes en arrière-plan pour les torrents déjà en seeding
-- **Manuellement** depuis le bouton **Importer** dans la vue téléchargements
+Le mode Hardlink est recommandé quand le dossier des téléchargements et la médiathèque sont sur le même système de fichiers : le fichier n'est pas copié et le client torrent continue de le partager.
-Le **scan médiathèque** permet de référencer des fichiers déjà présents sur le disque sans les déplacer — utile si vous avez déjà une bibliothèque existante.
+L'import se lance :
+- automatiquement, si l'import automatique est activé : FanKarr cherche les téléchargements terminés toutes les 5 minutes ;
+- à la main, avec le bouton **Importer** de la page Activité.
+
+L'analyse de la médiathèque (**Paramètres › Gestion des médias › Analyser la médiathèque**) reconnaît les fichiers déjà présents sur le disque sans les déplacer, utile pour une médiathèque existante.
---
-## Logs
+## Journaux
-
-
+
-> Tous les événements sont horodatés et filtrables par source (`organize`, `api`, `torrent`, …) et niveau (`info`, `warn`, `error`, `debug`).
+> Les journaux sont dans **Paramètres › Journaux**. Chaque événement est horodaté et filtrable par niveau (`info`, `warn`, `error`, `debug`) et par source : `api`, `organize`, `rss-sync`, `requests`, `jellyfin`, `plex`, `auth`, `torrent-clients`, une source par client torrent (`qbittorrent`, `transmission`…), etc.
+
+Le fichier garde environ les 2 000 dernières lignes. Les messages `debug` ne sont pas enregistrés quand `NODE_ENV=production`, ce qui est le cas dans l'image Docker.
---
-## Plugin Jellyfin — FanKarr Search
+## Plugin Jellyfin FanKarr Search
-Un plugin Jellyfin est disponible pour intégrer la recherche FanKarr directement dans l'interface de votre serveur. Vos utilisateurs peuvent parcourir le catalogue et demander des séries sans quitter Jellyfin.
+Un plugin Jellyfin intègre la recherche FanKarr à l'interface de votre serveur : vos utilisateurs parcourent le catalogue et demandent des séries sans quitter Jellyfin.
-👉 **[jellyfin-plugin-fankarr-search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search)**
+**[jellyfin-plugin-fankarr-search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search)**
-Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)** — consultez le README du plugin pour les instructions d'installation.
+Le plugin nécessite **[Jellyfin JavaScript Injector](https://github.com/n00bcodr/Jellyfin-JavaScript-Injector)**. Les instructions d'installation sont dans le README du plugin.
### Synchronisation des utilisateurs
-FanKarr peut créer automatiquement un compte pour chaque utilisateur Jellyfin actif. La synchronisation se fait :
-- **Automatiquement** toutes les heures en arrière-plan (si Jellyfin est configuré)
-- **Manuellement** depuis **Paramètres → Jellyfin → Synchroniser**
+FanKarr crée un compte invité pour chaque utilisateur Jellyfin actif qui n'en a pas encore (même nom d'utilisateur). La synchronisation se fait :
+- automatiquement toutes les heures, si Jellyfin est configuré ;
+- à la demande, depuis **Paramètres › Jellyfin et API › Synchroniser les utilisateurs**.
-Les comptes créés n'ont pas de mot de passe FanKarr — l'authentification se fait uniquement via SSO Jellyfin.
+Les comptes ainsi créés reçoivent un mot de passe aléatoire. Ces utilisateurs se connectent par le plugin, qui échange leur session Jellyfin contre leur jeton FanKarr.
---
-## API Publique
+## API publique
-FanKarr expose une API publique `v1` utilisée par le plugin Jellyfin et toute intégration tierce.
+FanKarr expose une API publique, sous `/api/v1`, utilisée par le plugin Jellyfin et ouverte aux intégrations tierces.
-**Auth** : `Authorization: Bearer ` (token visible dans **Profil → Token API**)
+**Authentification** : `Authorization: Bearer ` (jeton visible dans **Paramètres › Mon profil › Jeton d'API**)
-| Méthode | Endpoint | Description |
-|---------|----------|-------------|
-| `POST` | `/api/v1/auth/jellyfin` | Échange un token Jellyfin contre un token FanKarr |
-| `GET` | `/api/v1/auth/me` | Informations de l'utilisateur connecté |
-| `GET` | `/api/v1/series/search?q=` | Recherche dans le catalogue (titre, note, année, description, demande en cours) |
-| `GET` | `/api/v1/series/:id` | Détail d'une série — saisons et épisodes (proxy FanKai) |
-| `POST` | `/api/v1/requests` | Créer ou mettre à jour une demande |
-| `GET` | `/api/v1/requests` | Demandes de l'utilisateur authentifié |
+| Méthode | Route | Description |
+|---------|-------|-------------|
+| `POST` | `/api/v1/auth/jellyfin` | Échange une session Jellyfin contre le jeton FanKarr de l'utilisateur |
+| `GET` | `/api/v1/auth/me` | Compte associé au jeton |
+| `GET` | `/api/v1/series/search?q=` | Recherche par titre ; renvoie note, année, description et demande en cours |
+| `GET` | `/api/v1/series/:id` | Détail d'une série : saisons et épisodes |
+| `POST` | `/api/v1/requests` | Crée une demande ou complète la demande en cours |
+| `GET` | `/api/v1/requests` | Demandes de l'utilisateur du jeton |
-### SSO Jellyfin
+### Connexion avec Jellyfin
```http
POST /api/v1/auth/jellyfin
@@ -279,13 +306,13 @@ Content-Type: application/json
{ "jellyfinUserId": "...", "jellyfinToken": "..." }
```
-Retourne `{ token, username, role }`. Le `token` est ensuite utilisé comme Bearer pour tous les autres appels.
+Renvoie `{ token, username, role }`. Le `token` s'utilise ensuite en `Bearer` pour tous les autres appels. Si aucun compte FanKarr ne porte le nom de l'utilisateur Jellyfin, la réponse est une erreur 404 : lancez la synchronisation Jellyfin.
### Recherche
```http
GET /api/v1/series/search?q=dragon+ball
-Authorization: Bearer
+Authorization: Bearer
```
```json
@@ -308,19 +335,23 @@ Authorization: Bearer
]
```
-Le champ `request` est `null` si aucune demande active n'existe pour cette série.
+La recherche porte sur le titre et le titre original, sans tenir compte de la casse, parmi les séries présentes dans les données du scraper. Elle renvoie 50 résultats au maximum ; sans `q`, elle renvoie les 50 premières séries.
-### Détail série (saisons & épisodes)
+Le champ `request` vaut `null` si l'utilisateur n'a pas de demande en cours pour cette série.
+
+### Détail d'une série (saisons et épisodes)
```http
GET /api/v1/series/42
-Authorization: Bearer
+Authorization: Bearer
```
```json
{
"id": 42,
"title": "Dragon Ball Z Kai",
+ "original_title": "DRAGON BALL Z KAI",
+ "image": "https://...",
"seasons": [
{
"season_number": 1,
@@ -336,45 +367,48 @@ Authorization: Bearer
```http
POST /api/v1/requests
-Authorization: Bearer
+Authorization: Bearer
Content-Type: application/json
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
```
-- `seasons` : numéros de saison (`season_number`). Tableau vide = toutes les saisons.
-- `episodes` : IDs d'épisodes (`episode.id`). Prend le dessus sur `seasons` si renseigné.
-- Si une demande active existe déjà, les saisons/épisodes sont **fusionnés**.
+- `serieId` et `serieName` sont obligatoires.
+- `seasons` : numéros de saison (`season_number`). Sans saison ni épisode, la demande porte sur toute la série.
+- `episodes` : identifiants d'épisodes (`episode.id`). Prioritaires sur `seasons` s'ils sont renseignés.
+- Si une demande est déjà en cours pour cette série, les saisons et épisodes sont **fusionnés**.
+- Si l'approbation automatique est activée pour l'utilisateur, la demande est approuvée et le torrent envoyé au client.
---
## Runtipi
-FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
+FanKarr est disponible dans le dépôt d'applications Runtipi de Masutayunikon. 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**
+1. Dans Runtipi, ouvrez **Paramètres › App Stores**.
+2. Ajoutez l'adresse `https://github.com/Masutayunikon/runtipi-appstore`.
+3. FanKarr apparaît dans la liste des applications : cliquez sur **Installer**.
---
-## Stack technique
+## Technologies
-| Couche | Technologie |
-| ----------------- | ---------------------------------------- |
-| Frontend | Vue 3 + Vite + Tailwind CSS v4 |
-| Backend | Express 5 + TypeScript |
-| Auth | JWT + bcrypt |
-| Worker | Node Worker Threads (organisation async) |
-| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
-| Packaging binaire | Bun (self-contained, pas de Node requis) |
+| Partie | Technologie |
+| ----------------- | ------------------------------------------------- |
+| Interface | Vue 3, Vite, Tailwind CSS v4 |
+| Serveur | Express 5, TypeScript |
+| Authentification | JWT, bcrypt |
+| Import | Worker threads Node (import en arrière-plan) |
+| Image Docker | `node:22-slim`, pnpm, gosu (PUID/PGID) |
+| Binaire | Bun (exécutable autonome, sans Node) |
---
## Liens
-- [fankai.fr](https://fankai.fr) — Le projet Fankai
-- [Plugin Jellyfin FanKarr Search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search) — Recherche et demandes FanKarr dans Jellyfin
-- [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
-- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
\ No newline at end of file
+- [fankai.fr](https://fankai.fr) : le projet Fankai
+- [Plugin Jellyfin FanKarr Search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search) : recherche et demandes FanKarr dans Jellyfin
+- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) : reconnaissance des métadonnées Fankai dans Jellyfin et Emby
+- [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) : fournisseur de métadonnées Fankai pour Kodi
+- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) : collecte des torrents
+- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) : dépôt d'applications Runtipi
diff --git a/docker-compose.yml b/docker-compose.yml
index 468661c..457d354 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,14 +1,14 @@
-fankarr:
- image: masutayunikon/fankarr:latest
- container_name: fankarr
- environment:
- - PUID=1000 # UID de votre utilisateur (id -u)
- - PGID=1000 # GID de votre utilisateur (id -g)
- - TZ=Europe/Paris # Votre timezone
- volumes:
- - ./config:/config # Config, logs, base de données
- - /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
- ports:
- - 9898:9898
- restart: unless-stopped
-
\ No newline at end of file
+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 # Fuseau horaire
+ volumes:
+ - ./config:/config # Configuration, journaux et données
+ - /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
+ ports:
+ - 9898:9898
+ restart: unless-stopped
diff --git a/entrypoint.sh b/entrypoint.sh
index a99e9ae..458bc87 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -4,9 +4,9 @@ set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
-echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
+echo "[fankarr] Exécution en tant que UID=${PUID} GID=${PGID}"
-# Corriger les permissions du dossier data
+# Attribuer /config à PUID:PGID
chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js
\ No newline at end of file
diff --git a/index.html b/index.html
index df41c5b..d7dd8ca 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,13 @@
-
+
-
+
+
+
- Fankarr
+
+ FanKarr
diff --git a/server/auth.ts b/server/auth.ts
index 6017e9a..33d581f 100644
--- a/server/auth.ts
+++ b/server/auth.ts
@@ -8,7 +8,7 @@ import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import {
findByUsername, findById, findByApiToken,
- createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen,
+ createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
type User,
} from './users.js'
import { readSettings } from './settings.js'
@@ -21,7 +21,7 @@ declare global {
}
}
-// ── Durée JWT ─────────────────────────────────────────────────
+// ── Durée de session (JWT) ────────────────────────────────────
const RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY
@@ -34,6 +34,7 @@ function signToken(user: User): string {
}
function setCookieAndRespond(res: Response, user: User): void {
+ user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
const token = signToken(user)
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
res.json({ success: true, user: safeUser(user) })
@@ -65,25 +66,25 @@ export function authStatus(req: Request, res: Response): void {
// POST /api/auth/setup (premier lancement uniquement)
export function authSetup(req: Request, res: Response): void {
if (hasUsers()) {
- logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
+ logger.warn('auth', 'Tentative de création du premier compte alors qu\'un compte existe')
res.status(400).json({ error: 'Un compte existe déjà' }); return
}
const { username, password } = req.body
if (!username || !password) {
- res.status(400).json({ error: 'Username et password requis' }); return
+ res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
- res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
+ res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, 'admin')
startOnboarding()
- logger.info('auth', `Premier compte admin créé pour "${username}"`)
+ logger.info('auth', `Premier compte admin créé pour « ${username} »`)
setCookieAndRespond(res, user)
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -95,16 +96,16 @@ export function authLogin(req: Request, res: Response): void {
const { username, password } = req.body
if (!username || !password) {
- res.status(400).json({ error: 'Username et password requis' }); return
+ res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
- logger.warn('auth', `Échec de connexion pour "${username}"`)
+ logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' }); return
}
- logger.info('auth', `Connexion de "${username}" (rôle: ${user.role}, expiry: ${NO_EXPIRY ? 'aucune' : TOKEN_EXPIRY})`)
+ logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`)
setCookieAndRespond(res, user)
}
@@ -114,7 +115,7 @@ export function authLogout(req: Request, res: Response): void {
if (token) {
try {
const p = jwt.verify(token, JWT_SECRET) as any
- logger.info('auth', `Déconnexion de "${p.username}"`)
+ logger.info('auth', `Déconnexion de « ${p.username} »`)
} catch {}
}
res.clearCookie('fankarr_token')
@@ -130,16 +131,16 @@ export function authMe(req: Request, res: Response): void {
export function authChangePassword(req: Request, res: Response): void {
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
- res.status(400).json({ error: 'Mot de passe actuel et nouveau requis' }); return
+ res.status(400).json({ error: 'Mot de passe actuel et nouveau mot de passe requis' }); return
}
if (newPassword.length < 6) {
- res.status(400).json({ error: 'Le nouveau mot de passe doit faire au moins 6 caractères' }); return
+ res.status(400).json({ error: 'Le nouveau mot de passe doit contenir au moins 6 caractères' }); return
}
try {
changePassword(req.user!.id, currentPassword, newPassword)
res.json({ success: true })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -149,7 +150,7 @@ export function authRegenerateToken(req: Request, res: Response): void {
const token = regenerateApiToken(req.user!.id)
res.json({ apiToken: token })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
@@ -158,31 +159,17 @@ export function authTourSeen(req: Request, res: Response): void {
try {
res.json({ tourSeenAt: markTourSeen(req.user!.id) })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
}
-// POST /api/auth/register (via lien d'invitation)
-export function authRegister(req: Request, res: Response): void {
- const { username, password, inviteCode } = req.body
- if (!username || !password || !inviteCode) {
- res.status(400).json({ error: 'Username, password et code d\'invitation requis' }); return
- }
- if (password.length < 6) {
- res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
- }
- // La validation du code se fait dans le router invites (middleware)
- // Ici on reçoit déjà un username/password validés — la création est déléguée au router
- res.status(400).json({ error: 'Utilisez /api/invites/:code/register' })
-}
-
-// ── Middlewares ───────────────────────────────────────────────
+// ── Contrôle d'accès ─────────────────────────────────────────
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token
if (!token) {
- logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
- res.status(401).json({ error: 'Non authentifié' }); return
+ logger.debug('auth', `Accès refusé : non authentifié (${req.method} ${req.path})`)
+ res.status(401).json({ error: 'Session expirée, reconnectez-vous' }); return
}
try {
const payload = jwt.verify(token, JWT_SECRET) as any
@@ -193,22 +180,21 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
req.user = user
next()
} catch {
- logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
- res.status(401).json({ error: 'Token invalide' })
+ logger.warn('auth', `Jeton de session invalide (${req.method} ${req.path})`)
+ res.status(401).json({ error: 'Session expirée, reconnectez-vous' })
}
}
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
requireAuth(req, res, () => {
if (req.user?.role !== 'admin') {
- logger.warn('auth', `Accès admin refusé pour "${req.user?.username}" (${req.method} ${req.path})`)
+ logger.warn('auth', `Accès admin refusé pour « ${req.user?.username} » (${req.method} ${req.path})`)
res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
}
next()
})
}
-/** Accepte aussi l'authentification par token API (header Authorization: Bearer ) */
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) {
diff --git a/server/config.ts b/server/config.ts
index a8df75f..446ec9c 100644
--- a/server/config.ts
+++ b/server/config.ts
@@ -4,9 +4,7 @@ import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
-// ── Chargement anticipé du fichier .env pour les binaires bun ─────────────
-// Doit s'exécuter avant tout autre module qui lit process.env
-// (secret.ts, auth.ts…) car config.ts est la dépendance la plus profonde.
+// Charge le .env du binaire Bun avant tout module qui lit process.env
;(() => {
if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath)
@@ -21,17 +19,15 @@ export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
if (eq === -1) continue
const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim()
- // Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
- // Ne pas écraser les variables déjà définies (CLI/système prioritaires)
if (key && !(key in process.env)) process.env[key] = val
}
console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) {
- console.warn(`[config] Impossible de charger .env : ${err}`)
+ console.warn(`[config] Impossible de charger le fichier .env : ${err}`)
}
})()
@@ -40,19 +36,16 @@ export const BASE_DIR = _isBunBinary
: process.cwd()
const isInContainer = (): boolean => {
- // Docker
if (fs.existsSync('/.dockerenv')) return true;
- // Podman (crée un fichier /.containerenv)
+ // Podman
if (fs.existsSync('/.containerenv')) return true;
- // Vérification via cgroup (Docker, Podman, LXC, etc.)
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {}
- // Vérification via /proc/1/environ (fallback général)
try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true;
diff --git a/server/gitlab-map.ts b/server/gitlab-map.ts
index e1bfb1c..88d20dc 100644
--- a/server/gitlab-map.ts
+++ b/server/gitlab-map.ts
@@ -1,9 +1,4 @@
-/**
- * gitlab-map.ts
- * =============
- * Map des titres API → titres GitLab pour le téléchargement des NFO.
- * Partagé entre organize-worker.ts et index.ts.
- */
+/** Titres de l'API Fankai vers les noms de dossiers du dépôt GitLab des NFO. */
export const SERIE_TITLE_GITLAB_MAP: Record = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
diff --git a/server/index.ts b/server/index.ts
index 3eebe13..d5ab4bc 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -22,6 +22,7 @@ import { readAvailable, readInfohashMap } from './lib/github-cache.js'
import { loadCatalog } from './lib/serie-helpers.js'
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
import { pushNotif } from './lib/notifs.js'
+import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js'
import { runJellyfinSync } from './routes/jellyfin.js'
@@ -70,11 +71,11 @@ if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
-// ── Migration mono-user → multi-user ──────────────────────────
+// ── Migration vers le multi-utilisateur ───────────────────────
migrateIfNeeded()
migrateOnboarding()
-// ── Auth ───────────────────────────────────────────────────────
+// ── Authentification ───────────────────────────────────────────
app.get ('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
@@ -84,19 +85,16 @@ app.post('/api/auth/change-password', requireAuth, authChangePassword)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
-// ── API publique v1 (auth par token) ──────────────────────────
+// ── API publique v1 (authentification par jeton) ──────────────
app.use('/api', publicApiRouter)
-// ── Garde admin : protège les préfixes réservés aux admins ────
-// NOTE : app.use('/api', requireAdmin, router) s'applique à TOUTES
-// les requêtes /api/* avant même que le router vérifie ses routes,
-// ce qui bloquerait les utilisateurs standard. On utilise donc un
-// middleware ciblé sur les chemins réellement admin-only.
+// ── Garde admin par préfixe ───────────────────────────────────
+// requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import',
'/system', '/nfo-updates', '/plex', '/rss-sync',
- '/torrent', '/manual-import', '/organized', '/organized-summary',
+ '/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders',
'/rename-episode', '/rename-all', '/purge-nfo',
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
]
@@ -107,7 +105,7 @@ app.use('/api', (req, res, next) => {
next()
})
-// ── Routers (admin + utilisateurs connectés) ───────────────────
+// ── Routes (admins et utilisateurs connectés) ──────────────────
app.use('/api', usersRouter)
app.use('/api', jellyfinRouter)
app.use('/api', settingsRouter)
@@ -119,12 +117,11 @@ app.use('/api', systemRouter)
app.use('/api', nfoUpdatesRouter)
app.use('/api', plexRouter)
app.use('/api', rssSyncRouter)
+app.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter)
-// Invites : mix public + admin (le router gère ses propres middlewares)
-app.use('/api', invitesRouter)
-// ── Catch-all SPA ──────────────────────────────────────────────
+// ── Application web (toutes les autres routes) ─────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
@@ -134,32 +131,31 @@ const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => {
const url = `http://localhost:${PORT}`
- console.log(`\n FanKarr → \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
- logger.info('api', `Serveur démarré — ${url}`)
+ console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
+ logger.info('api', `Serveur démarré sur ${url}`)
try {
const available = await readAvailable()
- logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
+ logger.info('api', `Catalogue chargé : ${available.length} séries`)
} catch (err) {
- logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Impossible de charger le catalogue (available.json) au démarrage : ${err instanceof Error ? err.message : err}`)
}
const { mediaPath } = readSettings()
- // Migration organized.json : si ancien format (valeurs string) → reset + rescan
+ // Ancien format d'organized.json (valeurs texte) : réinitialisation, l'analyse le reconstruit
try {
const raw = readOrganized()
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
- logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
+ logger.info('api', 'Migration organized.json : ancien format détecté, fichier réinitialisé')
writeOrganized({})
}
} catch (err) {
- logger.error('api', `Migration organized.json ignorée : ${err instanceof Error ? err.message : err}`)
+ logger.error('api', `Migration organized.json annulée : ${err instanceof Error ? err.message : err}`)
}
- // Migration organized.json : backfill dest_dir manquant sur les anciennes entrées
try {
const raw = readOrganized()
let patched = 0
@@ -173,10 +169,10 @@ server.listen(PORT, async () => {
}
if (patched > 0) {
writeOrganized(raw)
- logger.info('api', `Migration organized.json : ${patched} entrée(s) backfillées avec dest_dir`)
+ logger.info('api', `Migration organized.json : ${patched} entrée(s) complétée(s) (dest_dir)`)
}
} catch (err) {
- logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Échec de la migration dest_dir : ${err instanceof Error ? err.message : err}`)
}
loadCatalog()
@@ -185,9 +181,11 @@ server.listen(PORT, async () => {
dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
- .catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
+ .catch(err => logger.error('api', `Échec de l'analyse initiale de la médiathèque : ${err instanceof Error ? err.message : err}`))
const autoOrganize = async () => {
+ autoImportSchedule.lastRunAt = new Date().toISOString()
+ planNextAutoImport()
try {
const { category } = readSettings()
const infohashMap = await readInfohashMap()
@@ -199,7 +197,7 @@ server.listen(PORT, async () => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
}
- // Marquer les demandes actives comme disponibles quand des fichiers sont importés
+ // Demandes actives passées en « disponible » dès qu'un fichier est importé
if (result.done > 0 && result.serieId != null) {
try {
const toComplete = readRequests().filter(r =>
@@ -208,73 +206,71 @@ server.listen(PORT, async () => {
)
for (const r of toComplete) {
completeRequest(r.id)
- logger.info('requests', `Demande "${r.serieName}" passée en disponible (import automatique)`)
+ logger.info('requests', `Demande « ${r.serieName} » passée en disponible (import automatique)`)
}
} catch (err) {
- logger.warn('api', `Impossible de compléter les demandes pour série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Impossible de passer en « disponible » les demandes de la série ${result.serieId} : ${err instanceof Error ? err.message : err}`)
}
}
const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) {
- logger.info('api', `Torrent "${result.name}" supprimé après move`)
+ logger.info('api', `Torrent « ${result.name} » retiré du client après déplacement`)
} else {
- logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
+ logger.warn('api', `Échec du retrait de « ${result.name} » après déplacement : ${removeResult.error ?? 'aucun client n\'a pu le supprimer'}`)
}
}
}
)
} catch (err) {
- logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
+ logger.error('api', `Échec de l'import automatique : ${err instanceof Error ? err.message : err}`)
}
}
+ planNextAutoImport(10_000)
setTimeout(() => {
autoOrganize()
- setInterval(autoOrganize, 5 * 60_000)
+ setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
}, 10_000)
setTimeout(() => {
- checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
+ checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
- checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
+ checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 30_000)
- // Sync automatique des noms de fichiers toutes les heures.
- // Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
- logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
+ logger.info('api', `Migration automatique des IDs : ${updated} ID(s) mis à jour`)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0)
- logger.info('api', `Dédoublonnage auto — ${removed} entrée(s) redondante(s) supprimée(s)`)
+ logger.info('api', `Dédoublonnage automatique : ${removed} entrée(s) redondante(s) supprimée(s)`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
- logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
+ logger.info('api', `Renommage automatique : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
- logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
+ logger.error('api', `Échec de la mise à jour horaire des imports : ${err instanceof Error ? err.message : err}`)
}
}, 60 * 60_000)
- // RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
setTimeout(() => {
- runRssSync().catch(err => logger.error('rss-sync', `Sync initial échoué : ${err instanceof Error ? err.message : err}`))
+ runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
- runRssSync().catch(err => logger.error('rss-sync', `Sync périodique échoué : ${err instanceof Error ? err.message : err}`))
+ runRssSync().catch(err => logger.error('rss-sync', `Échec de la surveillance des nouveaux épisodes : ${err instanceof Error ? err.message : err}`))
}, 6 * 60 * 60_000)
- }, 60_000) // premier cycle 1 min après le démarrage
+ }, 60_000)
- // Jellyfin sync — toutes les heures (no-op si Jellyfin non configuré)
+ // Sans effet si Jellyfin n'est pas configuré
setTimeout(() => {
- runJellyfinSync().catch(err => logger.error('jellyfin', `Sync initiale échouée : ${err instanceof Error ? err.message : err}`))
+ runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
- runJellyfinSync().catch(err => logger.error('jellyfin', `Sync périodique échouée : ${err instanceof Error ? err.message : err}`))
+ runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
- }, 2 * 60_000) // premier cycle 2 min après le démarrage
+ }, 2 * 60_000)
})
diff --git a/server/invites.ts b/server/invites.ts
index 8b0ebaa..7a6a214 100644
--- a/server/invites.ts
+++ b/server/invites.ts
@@ -1,6 +1,3 @@
-/**
- * Système d'invitations
- */
import fs from 'fs'
import path from 'path'
@@ -20,7 +17,7 @@ export interface Invite {
const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
-// ── Helpers fichier ───────────────────────────────────────────
+// ── Fichier des invitations ───────────────────────────────────
export function readInvites(): Invite[] {
try {
@@ -34,7 +31,7 @@ function writeInvites(invites: Invite[]): void {
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
}
-// ── CRUD ──────────────────────────────────────────────────────
+// ── Gestion des invitations ───────────────────────────────────
export function createInvite(opts: {
createdBy: string
@@ -49,12 +46,12 @@ export function createInvite(opts: {
expiresAt: opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
: null,
- maxUses : opts.maxUses ?? 1,
+ maxUses : opts.maxUses === undefined ? 1 : opts.maxUses,
uses : 0,
note : opts.note,
}
writeInvites([...readInvites(), invite])
- logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (maxUses: ${invite.maxUses ?? '∞'}, expire: ${invite.expiresAt ?? 'jamais'})`)
+ logger.info('invites', `Invitation créée : ${invite.code.slice(0, 8)}… (utilisations max : ${invite.maxUses ?? '∞'}, expiration : ${invite.expiresAt ?? 'jamais'})`)
return invite
}
@@ -68,10 +65,9 @@ export function findInvite(code: string): Invite | undefined {
return readInvites().find(i => i.code === code)
}
-/** Vérifie si un code est valide (non expiré, non épuisé) */
export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
const invite = findInvite(code)
- if (!invite) return { valid: false, reason: 'Code d\'invitation invalide' }
+ if (!invite) return { valid: false, reason: 'Ce lien d\'invitation n\'est pas valide' }
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { valid: false, reason: 'Ce lien d\'invitation a expiré' }
}
@@ -81,7 +77,6 @@ export function validateInvite(code: string): { valid: true; invite: Invite } |
return { valid: true, invite }
}
-/** Incrémente le compteur d'utilisations */
export function consumeInvite(code: string): void {
const invites = readInvites()
const invite = invites.find(i => i.code === code)
diff --git a/server/lib/github-cache.ts b/server/lib/github-cache.ts
index ddae1a3..c770ced 100644
--- a/server/lib/github-cache.ts
+++ b/server/lib/github-cache.ts
@@ -8,7 +8,7 @@ const GITHUB_BASE =
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
-interface CacheEntry { data: T; expiresAt: number }
+interface CacheEntry { data: T; expiresAt: number; fetchedAt: number }
const _cache = new Map>()
export function cacheGet(key: string): T | null {
@@ -19,17 +19,19 @@ export function cacheGet(key: string): T | null {
}
export function cacheSet(key: string, data: T): void {
- _cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
+ _cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
+}
+
+export function cacheFetchedAt(key: string): string | null {
+ const entry = _cache.get(key)
+ return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
}
export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
-/**
- * @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
- * (~5 min).
- */
+/** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
export async function githubGet(urlPath: string, force = false): Promise {
if (!force) {
const cached = cacheGet(urlPath)
@@ -38,7 +40,7 @@ export async function githubGet(urlPath: string, force = false): Promise {
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
- if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
+ if (!res.ok) throw new Error(`GitHub : HTTP ${res.status} sur ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
diff --git a/server/lib/jellyfin.ts b/server/lib/jellyfin.ts
index 5ee4d68..e903eff 100644
--- a/server/lib/jellyfin.ts
+++ b/server/lib/jellyfin.ts
@@ -1,7 +1,3 @@
-/**
- * Helpers pour l'API Jellyfin
- * Utilisé pour la sync d'utilisateurs et la validation SSO
- */
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
@@ -39,7 +35,7 @@ export async function testJellyfinConnection(url: string, token: string): Promis
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
- if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
+ if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
@@ -51,15 +47,15 @@ export async function testJellyfinConnection(url: string, token: string): Promis
export async function fetchJellyfinUsers(): Promise {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
- if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou token manquant)')
+ if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
- if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`)
+ if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
return res.json()
}
-// ── Validation d'un token utilisateur Jellyfin (SSO) ──────────
+// ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo {
valid : boolean
@@ -77,13 +73,13 @@ export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToke
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken),
})
- if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' }
- if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` }
+ if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
+ if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
- logger.debug('jellyfin', `Token valide pour "${user.Name}" (${user.Id.slice(0, 8)}…)`)
+ logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
diff --git a/server/lib/nfo.ts b/server/lib/nfo.ts
index bc94f78..39bbc95 100644
--- a/server/lib/nfo.ts
+++ b/server/lib/nfo.ts
@@ -57,7 +57,7 @@ export async function downloadNfoFolder(gitlabTitle: string, destRoot: string):
downloaded++
} catch { skipped++ }
}
- logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
+ logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
}
export async function checkNfoUpdates() {
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
- logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
+ logger.info('nfo-update', `Mise à jour des NFO disponible pour « ${gitlabTitle} » (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.error('nfo-update', `Échec de la mise à jour des NFO de « ${gitlabTitle} » : ${msg}`)
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
}
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
diff --git a/server/lib/notifs.ts b/server/lib/notifs.ts
index 4860501..3dd850c 100644
--- a/server/lib/notifs.ts
+++ b/server/lib/notifs.ts
@@ -1,12 +1,54 @@
+import fs from 'fs'
+import path from 'path'
+import { DATA_DIR } from '../config.js'
+
export interface OrganizeNotif {
- hash: string; name: string; done: number; skipped: number; errors: number
+ hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number
errorFiles: { file: string; error: string }[]; at: string
}
-export const recentOrganized: OrganizeNotif[] = []
-const MAX_NOTIFS = 20
+export interface RecentSerieImport { serieId: number; episodes: number; at: string }
+
+const HISTORY_PATH = path.join(DATA_DIR, 'import-history.json')
+const MAX_HISTORY = 200
+const RECENT_WINDOW_MS = 7 * 24 * 60 * 60_000
+
+function loadHistory(): OrganizeNotif[] {
+ try {
+ if (!fs.existsSync(HISTORY_PATH)) return []
+ const data = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8'))
+ return Array.isArray(data) ? data.slice(0, MAX_HISTORY) : []
+ } catch { return [] }
+}
+
+function saveHistory(): void {
+ try {
+ fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true })
+ fs.writeFileSync(HISTORY_PATH, JSON.stringify(recentOrganized, null, 2))
+ } catch {}
+}
+
+// Du plus récent au plus ancien
+export const recentOrganized: OrganizeNotif[] = loadHistory()
export function pushNotif(n: OrganizeNotif) {
recentOrganized.unshift(n)
- if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
+ if (recentOrganized.length > MAX_HISTORY) recentOrganized.length = MAX_HISTORY
+ saveHistory()
+}
+
+export function clearNotifs() {
+ recentOrganized.length = 0
+ saveHistory()
+}
+
+export function recentImportsBySerie(): RecentSerieImport[] {
+ const bySerie = new Map()
+ for (const n of recentOrganized) {
+ if (n.serieId == null || n.done <= 0) continue
+ const current = bySerie.get(n.serieId)
+ if (!current) { bySerie.set(n.serieId, { serieId: n.serieId, episodes: n.done, at: n.at }); continue }
+ if (Date.parse(current.at) - Date.parse(n.at) <= RECENT_WINDOW_MS) current.episodes += n.done
+ }
+ return [...bySerie.values()]
}
diff --git a/server/lib/organized-store.ts b/server/lib/organized-store.ts
index 0ddaee8..fbbbd6f 100644
--- a/server/lib/organized-store.ts
+++ b/server/lib/organized-store.ts
@@ -13,8 +13,8 @@ export function readOrganized(file = ORGANIZED_PATH): Organized {
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
- catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) }
- if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu')
+ catch (err) { throw new Error(`Fichier de suivi des imports (organized.json) corrompu : ${err instanceof Error ? err.message : err}`) }
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Fichier de suivi des imports (organized.json) corrompu : contenu inattendu')
return data as Organized
}
diff --git a/server/lib/request-scope.ts b/server/lib/request-scope.ts
new file mode 100644
index 0000000..7b93e0f
--- /dev/null
+++ b/server/lib/request-scope.ts
@@ -0,0 +1,77 @@
+export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
+
+/** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
+export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
+ const toDownload: RequestTorrent[] = []
+ const seenHashes = new Set()
+
+ const allPackTorrents: any[] = [
+ ...(serieData.torrents ?? []),
+ ...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
+ ]
+
+ function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
+ const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
+ if (seenHashes.has(key)) return
+ seenHashes.add(key)
+ toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
+ }
+
+ function addEpisodesByIndex(epList: any[]) {
+ for (const ep of epList) {
+ if (ep.torrents?.length > 0) {
+ addTorrent(ep.torrents[0])
+ } else if (ep.paths?.length > 0) {
+ const p = ep.paths[0]
+ const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
+ if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
+ }
+ }
+ }
+
+ if (episodes.length > 0) {
+ // ── Épisodes ciblés ───────────────────────────────────────
+ for (const season of serieData.seasons ?? []) {
+ const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
+ addEpisodesByIndex(targets)
+ }
+ } else if (seasons.length > 0) {
+ // ── Saisons ciblées ───────────────────────────────────────
+ for (const season of serieData.seasons ?? []) {
+ if (!seasons.includes(season.season_number)) continue
+ if ((season.torrents ?? []).length > 0) {
+ addTorrent(season.torrents[0])
+ } else {
+ addEpisodesByIndex(season.episodes ?? [])
+ }
+ }
+ } else {
+ // ── Toutes les saisons : intégrale en priorité ────────────
+ if ((serieData.torrents ?? []).length > 0) {
+ addTorrent(serieData.torrents[0])
+ } else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
+ for (const season of serieData.seasons ?? []) {
+ if (season.torrents?.length > 0) addTorrent(season.torrents[0])
+ }
+ } else {
+ for (const season of serieData.seasons ?? []) {
+ addEpisodesByIndex(season.episodes ?? [])
+ }
+ }
+ }
+
+ return toDownload
+}
+
+export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
+ const keys = new Set()
+ for (const season of serieData.seasons ?? []) {
+ for (const ep of season.episodes ?? []) {
+ const inScope = episodes.length > 0
+ ? episodes.includes(ep.id)
+ : seasons.length === 0 || seasons.includes(season.season_number)
+ if (inScope) keys.add(`${season.season_number}:${ep.episode_number}`)
+ }
+ }
+ return keys.size
+}
diff --git a/server/lib/rss-sync.ts b/server/lib/rss-sync.ts
index 9eb0c67..95b49b2 100644
--- a/server/lib/rss-sync.ts
+++ b/server/lib/rss-sync.ts
@@ -1,11 +1,4 @@
-/**
- * RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance.
- *
- * Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added`
- * (date d'ajout dans la base metadata) est postérieur à la date d'activation
- * du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont
- * pas encore organisés, ils sont envoyés automatiquement au client torrent.
- */
+/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
import path from 'path'
import fs from 'fs'
@@ -21,7 +14,7 @@ import { readSettings } from '../settings.js'
export interface SyncedSerie {
serieId : number
serieName: string
- addedAt : string // ISO — date d'activation de la surveillance
+ addedAt : string // date ISO d'activation de la surveillance
}
type SyncedMap = Record
@@ -73,8 +66,7 @@ export function isSynced(serieId: number): boolean {
const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
-// Série recréée sous un nouvel ID avec le même nom : la surveillance suit.
-// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
+// Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id))
@@ -86,7 +78,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
- logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
+ logger.info('rss-sync', `Surveillance de « ${entry.serieName} » déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
changed = true
} else {
orphans.push(entry)
@@ -96,7 +88,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
return orphans
}
-// ── Helpers ───────────────────────────────────────────────────
+// ── Épisodes déjà importés ────────────────────────────────────
function readOrganized(): Record> {
try {
@@ -144,7 +136,7 @@ function getOrganizedEpisodeIds(sd: any, organized: Record {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
@@ -154,7 +146,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const synced = loadSynced()
const ids = Object.keys(synced).map(Number)
- logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
+ logger.info('rss-sync', `Surveillance : vérification de ${ids.length} série(s)`)
const organized = readOrganized()
@@ -176,7 +168,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) {
- logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
+ logger.warn('rss-sync', `Série ${serieId} (« ${syncedEntry.serieName} ») absente du catalogue : retirez la surveillance dans Paramètres › Catalogue`)
continue
}
@@ -184,9 +176,8 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized)
- logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`)
+ logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
- // Épisodes candidats : date_added > date d'activation ET pas encore organisés
const candidateIds = new Set()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -194,7 +185,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const epDate = new Date(ep.date_added)
if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) continue
- // Doit avoir au moins un torrent fankai
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue
candidateIds.add(ep.id)
@@ -202,17 +192,15 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
if (candidateIds.size === 0) {
- logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`)
+ logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
continue
}
- logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`)
+ logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`)
- // Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
const coveredIds = new Set()
- // 1. Packs saison couvrant au moins un candidat
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (!t.fankai) continue
@@ -231,7 +219,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
}
- // 2. Épisodes individuels candidats non couverts par un pack
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
@@ -265,19 +252,19 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
infohash : dl.infohash,
})
if (results.some((r: any) => r.ok)) {
- logger.info('rss-sync', `Envoyé : ${dl.label}`)
+ logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
sent++
} else {
- logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
+ logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
errors++
}
} catch (err) {
- logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
+ logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
errors++
}
}
}
- logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
+ logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
return { sent, skipped, errors }
}
diff --git a/server/lib/schedule.ts b/server/lib/schedule.ts
new file mode 100644
index 0000000..b2a1fb5
--- /dev/null
+++ b/server/lib/schedule.ts
@@ -0,0 +1,10 @@
+export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
+
+export const autoImportSchedule = {
+ lastRunAt: null as string | null,
+ nextRunAt: null as string | null,
+}
+
+export function planNextAutoImport(delayMs = AUTO_IMPORT_INTERVAL_MS) {
+ autoImportSchedule.nextRunAt = new Date(Date.now() + delayMs).toISOString()
+}
diff --git a/server/lib/serie-helpers.ts b/server/lib/serie-helpers.ts
index 9d306f1..28768dd 100644
--- a/server/lib/serie-helpers.ts
+++ b/server/lib/serie-helpers.ts
@@ -6,7 +6,7 @@ const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise {
const res = await fetch(`${FANKAI_API}${endpoint}`)
- if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
+ if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`)
return res.json()
}
@@ -37,26 +37,50 @@ export function seasonFolderName(seasonNumber: number, englishDirectory: boolean
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
-// Données scraper avec le titre à jour de l'API, ou repli complet sur l'API quand le scraper n'a pas (encore) la série
+// Le scraper écarte les épisodes qu'il n'associe à aucun fichier de torrent : on les reprend de l'API, sans torrent
+function addApiEpisodes(sd: any, api: any): number {
+ const known = new Set()
+ for (const season of sd.seasons ?? [])
+ for (const ep of season.episodes ?? []) {
+ known.add(ep.id)
+ for (const v of Object.values(ep.codec_variants ?? {})) known.add(v.id)
+ }
+ let added = 0
+ for (const apiSeason of api.seasons ?? []) {
+ const eps = (apiSeason.episodes ?? []).filter((ep: any) => !known.has(ep.id))
+ if (eps.length === 0) continue
+ let season = sd.seasons.find((s: any) => s.id === apiSeason.id)
+ if (!season) sd.seasons.push(season = { ...apiSeason, torrents: [], episodes: [] })
+ season.episodes.push(...eps.map((ep: any) => ({ ...ep, torrents: [], paths: [] })))
+ season.episodes.sort((a: any, b: any) => a.episode_number - b.episode_number)
+ added += eps.length
+ }
+ sd.seasons.sort((a: any, b: any) => a.season_number - b.season_number)
+ return added
+}
+
+// Données scraper complétées par l'API (titre, épisodes manquants), ou repli complet sur l'API quand le scraper n'a pas (encore) la série
export async function resolveSerieData(serieId: number, action?: string): Promise {
- const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fankaiGet(`/series/${serieId}`)])
- if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value))
- return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value
- const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
- try {
- const sd = await fetchSerieFromApi(serieId)
- if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
+ const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fetchSerieFromApi(serieId)])
+ if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value)) {
+ if (api.status !== 'fulfilled') return scraper.value
+ const sd = { ...structuredClone(scraper.value), title: api.value.title ?? scraper.value.title }
+ const added = addApiEpisodes(sd, api.value)
+ if (added > 0 && action) logger.warn('api', `${action} : ${added} épisode(s) de la série ${serieId} absent(s) du scraper, repris de l'API Fankai`)
return sd
- } catch (err) {
- logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
+ }
+ const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
+ if (api.status === 'rejected') {
+ logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(api.reason)})`)
return null
}
+ if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
+ return api.value
}
let lastCatalogWarn = 0
-// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
-// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
+// Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
@@ -121,14 +145,6 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
-/**
- * Résout les noms d'un épisode en préférant les valeurs du path entry
- * correspondant au hash donné (spécifique au torrent/encoding),
- * avec fallback sur les champs de niveau épisode.
- *
- * Utilisation :
- * const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
- */
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
@@ -145,10 +161,6 @@ export function resolveEpNaming(ep: any, hash?: string | null): {
}
}
-/**
- * Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
- * Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
- */
export function computeExpectedName(
ep : any,
orgEntry: any,
@@ -164,11 +176,7 @@ export function computeExpectedName(
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
-/**
- * Dédoublonne une liste d'épisodes par episode_number.
- * Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
- * comme épisode principal et on fusionne les torrents des autres dans sa liste.
- */
+/** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map()
for (const ep of episodes) {
@@ -212,14 +220,21 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb
return resolved
}
-export function computeSerieDownloadState(serieData: any | null, organized: Record>, activeTorrents: Set): 'none' | 'downloading' | 'partial' | 'complete' {
+export function computeSerieDownloadState(
+ serieData : any | null,
+ organized : Record>,
+ activeTorrents: Set,
+ counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 },
+): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
- const allTorrents = extractTorrentsFromSerieData(serieData)
+ if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
+ if (counts.total === 0 || counts.organized === 0) return 'none'
+ if (counts.organized >= counts.total) return 'complete'
+ return 'partial'
+}
- if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
-
- // Dédoublonnage : épisodes uniques par (season_number, episode_number)
- // pour éviter de compter x264 + x265 comme deux épisodes distincts
+export function countOrganizedEpisodes(serieData: any, organized: Record>): { total: number; organized: number } {
+ // Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
const uniqueEpKeys = new Set()
const epIdToKey = new Map()
for (const season of serieData.seasons ?? []) {
@@ -232,7 +247,7 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
const organizedEpKeys = new Set()
- for (const t of allTorrents) {
+ for (const t of extractTorrentsFromSerieData(serieData)) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const orgFiles = organized[hash] ?? {}
@@ -250,7 +265,5 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
for (const [id, key] of epIdToKey)
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
- if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
- if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
- return 'partial'
+ return { total: uniqueEpKeys.size, organized: organizedEpKeys.size }
}
diff --git a/server/logger.ts b/server/logger.ts
index c969165..59e7a19 100644
--- a/server/logger.ts
+++ b/server/logger.ts
@@ -1,10 +1,3 @@
-/**
- * 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'
@@ -13,15 +6,15 @@ import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface LogEntry {
- at : string // ISO timestamp
+ at : string // date ISO
level : LogLevel
- source : string // 'organize' | 'api' | 'scraper' | ...
+ source : string // 'api', 'organize', 'torrent-clients', 'rss-sync'…
msg : string
meta? : Record
}
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
-const MAX_LINES = 2000 // rotation auto au-delà
+const MAX_LINES = 2000 // rotation automatique au-delà
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
const IS_PROD = process.env.NODE_ENV === 'production'
@@ -51,14 +44,12 @@ function writeLine(entry: LogEntry) {
export function log(level: LogLevel, source: string, msg: string, meta?: Record) {
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)
}
@@ -73,9 +64,9 @@ export const logger = {
// ── Lecture pour l'API ────────────────────────────────────────
export interface LogsReadOptions {
- limit? : number // nb de lignes (défaut 100)
- level? : LogLevel | 'all' // filtre niveau
- source? : string // filtre source
+ limit? : number // nombre de lignes (100 par défaut)
+ level? : LogLevel | 'all'
+ source? : string
}
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
@@ -84,22 +75,18 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
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 [] }
diff --git a/server/onboarding.ts b/server/onboarding.ts
index 7b016fa..70cfc27 100644
--- a/server/onboarding.ts
+++ b/server/onboarding.ts
@@ -20,7 +20,7 @@ export function readOnboarding(): OnboardingState {
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt }
}
-// Installation antérieure à l'assistant : déjà configurée → rien n'est imposé
+// Installation configurée avant l'assistant : on ne l'impose pas
export function migrateOnboarding(): boolean {
const settings = readSettings()
if (settings.onboardingCompletedAt || settings.onboardingStep) return false
@@ -29,7 +29,7 @@ export function migrateOnboarding(): boolean {
const now = new Date().toISOString()
writeSettings({ onboardingCompletedAt: now })
markAllToursSeen(now)
- logger.info('onboarding', 'Installation existante détectée — assistant marqué comme terminé')
+ logger.info('onboarding', 'Installation existante détectée : assistant marqué comme terminé')
return true
}
diff --git a/server/organize-worker.ts b/server/organize-worker.ts
index 01d58c2..585d8ed 100644
--- a/server/organize-worker.ts
+++ b/server/organize-worker.ts
@@ -1,8 +1,3 @@
-/**
- * organize-worker.ts
- * ==================
- * Worker thread dédié à l'organisation des fichiers.
- */
import fs from 'fs'
import fsp from 'fs/promises'
@@ -12,7 +7,7 @@ import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js'
-// ─── Utils log ────────────────────────────────────────────────
+// ─── Journal ──────────────────────────────────────────────────
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 }) }
@@ -33,7 +28,7 @@ export interface OrgEntry {
// organized.json : Record>
type Organized = Record>
-// ─── Settings ─────────────────────────────────────────────────
+// ─── Réglages ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
@@ -42,7 +37,7 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
}
-// ─── Organized log ────────────────────────────────────────────
+// ─── Suivi des imports ────────────────────────────────────────
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
}
@@ -51,11 +46,8 @@ function isOrganized(hash: string, episodeId: number): boolean {
return !!readOrganized()[hash]?.[String(episodeId)]
}
-// ─── Lookup torrent dans les seriesData ───────────────────────
+// ─── Recherche du torrent dans le catalogue ───────────────────
-/**
- * Recherche par infohash (méthode principale).
- */
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
@@ -94,11 +86,7 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null
}
-/**
- * Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
- * Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
- * Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
- */
+/** Hash non résolu (ex. dbid_X Synology) : recherche par torrent_name ou title, renvoie le vrai infohash. */
function findTorrentByName(
name : string,
seriesData: any[],
@@ -117,7 +105,6 @@ function findTorrentByName(
}
for (const season of sd.seasons ?? []) {
- // Pack saison
for (const t of season.torrents ?? []) {
if (!t.infohash) continue
if (
@@ -126,7 +113,6 @@ function findTorrentByName(
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
}
- // Torrent épisode unique
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (!t.infohash) continue
@@ -170,7 +156,6 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
- // Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
@@ -179,7 +164,7 @@ function buildFileMap(
: null
if (!nfoSupport && !fmtName) {
- warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
+ warn(`formatted_name absent pour l'épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) : nom NFO ou nom d'origine utilisé`)
}
const srcExt = path.extname(filename)
@@ -218,7 +203,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
-// ─── GitLab NFO downloader ────────────────────────────────────
+// ─── Téléchargement des NFO ───────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -239,7 +224,7 @@ async function fetchBinary(url: string): Promise {
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise {
const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
- log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
+ log(`Téléchargement des NFO et images depuis GitLab pour « ${gitlabTitle} »`)
let files: any[] = []
try {
@@ -254,14 +239,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++
}
} catch (err) {
- warn(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
+ warn(`Dossier GitLab introuvable pour « ${gitlabTitle} » : ${err instanceof Error ? err.message : err}`)
return
}
- if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
+ if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour « ${gitlabTitle} »`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
- log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
+ log(`${fileEntries.length} fichier(s) NFO et images à télécharger pour « ${gitlabTitle} »`)
let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) {
@@ -275,14 +260,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
fs.writeFileSync(destPath, data)
downloaded++
} catch (err) {
- warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
+ warn(`Échec du téléchargement de « ${relativePath} » : ${err instanceof Error ? err.message : err}`)
failed++
}
}
- log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
+ log(`NFO de « ${gitlabTitle} » : ${downloaded} téléchargé(s), ${skipped} déjà présent(s)${failed > 0 ? `, ${failed} échec(s)` : ''}`)
}
-// ─── Filesystem ops ───────────────────────────────────────────
+// ─── Fichiers ─────────────────────────────────────────────────
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
@@ -295,10 +280,7 @@ function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
}
-/**
- * Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
- * 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
- */
+/** Supprime un fichier en réessayant si EBUSY (fichier ouvert sous Windows). */
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
@@ -306,7 +288,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
- warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
+ warn(`Fichier « ${path.basename(filePath)} » occupé, nouvelle tentative (${attempt + 1}/${maxAttempts - 1})`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
@@ -318,7 +300,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
- debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
+ debug(`Hardlink impossible (${err instanceof Error ? err.message : err}), copie du fichier`)
return false
}
}
@@ -331,7 +313,7 @@ function sanitizeDirName(name: string): string {
.trim()
}
-// ─── Organise un torrent ──────────────────────────────────────
+// ─── Import d'un torrent ──────────────────────────────────────
async function organizeTorrent(
hash : string,
name : string,
@@ -344,7 +326,7 @@ async function organizeTorrent(
const found = findTorrentByHash(hash, seriesData)
if (!found) {
- error(`Hash ${hash} introuvable dans les données série`)
+ error(`Hash ${hash} introuvable dans le catalogue`)
throw new Error(`Torrent introuvable : ${hash}`)
}
@@ -352,13 +334,13 @@ async function organizeTorrent(
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(rawTitle)
- debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
+ debug(`Série identifiée : « ${serieTitle} » (mode : ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
- // ── Cas torrent épisode unique ────────────────────────────
+ // ── Torrent d'un seul épisode ─────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
@@ -374,7 +356,6 @@ async function organizeTorrent(
}
}
const filename = filePath?.split('/').pop() ?? name
- // Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
@@ -393,9 +374,8 @@ async function organizeTorrent(
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
- // Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
- warn(`Skip (incomplet / priorité 0) : ${filename}`)
+ warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
@@ -414,7 +394,7 @@ async function organizeTorrent(
if (!src) {
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
- return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
+ return { ...result, total: 1, errors: [{ file: filename, error: `Fichier source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
}
if (!fs.existsSync(dest)) {
@@ -431,7 +411,7 @@ async function organizeTorrent(
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
await unlinkWithRetry(src)
- log(`${destName} → Saison ${season.season_number}`)
+ log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
}
@@ -441,16 +421,16 @@ async function organizeTorrent(
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
})
- log(`${destName} → Saison ${season.season_number}`)
+ log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 }
}
- // ── Pack saison / intégrale ───────────────────────────────
+ // ── Pack saison ou intégrale ──────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) {
- warn(`Aucun fichier mappé pour "${name}"`)
+ warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
return result
}
@@ -459,10 +439,8 @@ async function organizeTorrent(
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
- // Vérification de complétion : si on a les infos de progression par fichier,
- // on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
- warn(`Skip (incomplet / priorité 0) : ${filename}`)
+ warn(`Ignoré (téléchargement incomplet ou fichier non sélectionné) : ${filename}`)
result.skipped++
continue
}
@@ -477,8 +455,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
- // Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
- warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
+ // Pas encore téléchargé ou hors sélection (priorité 0)
+ warn(`Ignoré (fichier absent du disque) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++
continue
}
@@ -522,10 +500,10 @@ async function organizeTorrent(
await unlinkWithRetry(src)
}
result.done++
- debug(`${nfo_filename} → Saison ${season_number}`)
+ debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- error(`Échec import "${filename}" : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ error(`Échec de l'import de « ${filename} » : ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
@@ -552,24 +530,21 @@ parentPort?.on('message', async (msg: any) => {
for (const t of torrents) {
if (t.state !== 'seeding') continue
- // Résolution principale : par infohash
let found = findTorrentByHash(t.hash, seriesData)
let effectiveHash = t.hash
- // Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
- // par torrent_name — le nom du dossier racine tel que rapporté par le client,
- // qui correspond au champ torrent_name dans les données série.
+ // Hash non résolu (ex. dbid_X Synology) : recherche par nom de dossier
if (!found && t.name) {
const byName = findTorrentByName(t.name, seriesData)
if (byName) {
found = byName
effectiveHash = byName.infohash
- debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
+ debug(`Hash résolu par nom de torrent : « ${t.name} » (${effectiveHash.slice(0, 8)}…)`)
}
}
if (!found) {
- debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
+ debug(`Torrent « ${t.name} » (${t.hash.slice(0, 8)}…) absent du catalogue (pas encore à jour ?)`)
continue
}
@@ -582,25 +557,20 @@ parentPort?.on('message', async (msg: any) => {
: Object.keys(orgHash).length > 0
if (allDone) {
- debug(`"${t.name}" déjà entièrement importé — skip`)
+ debug(`« ${t.name} » déjà entièrement importé`)
continue
}
try {
- // Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
- // On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
- // Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
+ // Fichiers terminés (nom seul) ; null si le client ne donne pas les noms
let completedFileNames: Set | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
- // Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
- // f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
- // on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
@@ -612,7 +582,7 @@ parentPort?.on('message', async (msg: any) => {
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) {
- error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
+ error(`Échec de l'import de « ${t.name} » :${err instanceof Error ? err.message : err}`)
}
}
diff --git a/server/organize.ts b/server/organize.ts
index 89e20c1..8c91b89 100644
--- a/server/organize.ts
+++ b/server/organize.ts
@@ -1,8 +1,3 @@
-/**
- * organize.ts
- * ===========
- * Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
- */
import fs from 'fs'
import path from 'path'
@@ -22,6 +17,7 @@ function resolveWorkerPath(): string {
}
export interface OrganizeResult {
+ serieId?: number | null
total : number
skipped : number
done : number
@@ -62,7 +58,7 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) {
- logger.error('organize', `Enregistrement de l'import impossible (ep ${msg.episodeId}) : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Échec de l'enregistrement de l'import (épisode ${msg.episodeId}) :${err instanceof Error ? err.message : err}`)
}
} else if (msg.type === 'result') {
onResult(msg)
@@ -71,13 +67,13 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
}
})
worker.on('error', err => finish(err))
- worker.on('exit', code => finish(code > 1 ? new Error(`Worker exit inattendu : code ${code}`) : undefined))
+ worker.on('exit', code => finish(code > 1 ? new Error(`Arrêt inattendu du worker (code ${code})`) : undefined))
worker.postMessage({ type: 'run', torrents, seriesData })
})
}
-// ── Scan initial ──────────────────────────────────────────────
+// ── Analyse de la médiathèque ─────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
@@ -85,6 +81,8 @@ function swapExt(filename: string, ext: string): string {
return filename.slice(0, -cur.length) + ext
}
+export let lastScan: { at: string; found: number; added: number } | null = null
+
export async function scanMediaPath(
mediaPath : string,
organizedPath: string,
@@ -93,11 +91,11 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) {
- logger.warn('organize', `Dossier médiathèque introuvable : ${mediaPath}`)
+ logger.warn('organize', `Dossier de la médiathèque introuvable : ${mediaPath}`)
return result
}
- logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
+ logger.info('organize', `Analyse de la médiathèque : ${mediaPath}`)
const filenameIndex = new Map {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
@@ -131,14 +128,12 @@ export async function scanMediaPath(
})
}
- // 1. Noms depuis les paths (avec vrai infohash)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash || !p.path) continue
const hash = p.infohash.toLowerCase()
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
- // nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
const pathNfo = p.nfo_filename ?? ep.nfo_filename
if (pathNfo) idx(pathNfo, hash, pathNfo)
const pathFmt = p.formatted_name ?? ep.formatted_name
@@ -148,7 +143,6 @@ export async function scanMediaPath(
}
}
- // 2. original_filename, nfo_filename et formatted_name épisode — fallback
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
@@ -163,11 +157,10 @@ export async function scanMediaPath(
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
- logger.error('organize', `Scan annulé : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Analyse annulée : ${err instanceof Error ? err.message : err}`)
return result
}
- // Track les paires hash:episodeId présentes
const presentFiles = new Set()
let noMatch = 0
@@ -202,8 +195,7 @@ export async function scanMediaPath(
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt)
- // Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier
- // ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
+ // À défaut, un nom de l'index qui préfixe le fichier (ex. "X.S01E01.MULTI.1080p" pour "X.S01E01.MULTI.1080p.x264-FANKAI")
if (!match) {
const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) {
@@ -218,8 +210,8 @@ export async function scanMediaPath(
noMatch++
const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3)
- const closeStr = close.length > 0 ? ` | candidats : ${close.map(c => `"${c}"`).join(', ')}` : ' | aucun candidat proche'
- logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`)
+ const closeStr = close.length > 0 ? ` (candidats : ${close.map(c => `« ${c} »`).join(', ')})` : ' (aucun candidat proche)'
+ logger.warn('organize', `Analyse : aucune correspondance pour « ${nameWithoutExt} »${closeStr}`)
continue
}
@@ -236,11 +228,11 @@ export async function scanMediaPath(
episode,
episode_id : episodeId,
src_filename : srcFilename,
- dest_filename: entry.name, // vrai nom sur disque
+ dest_filename: entry.name,
dest_path : full,
}
result.added++
- logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
+ logger.debug('organize', `Analyse : « ${entry.name} » associé à l'épisode ${episodeId} (S${season}E${episode})`)
}
}
}
@@ -248,7 +240,7 @@ export async function scanMediaPath(
walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0
- if (mediaEmpty) logger.warn('organize', `Scan : aucun fichier dans ${mediaPath} — suppression des entrées ignorée`)
+ if (mediaEmpty) logger.warn('organize', `Analyse : aucun fichier trouvé dans ${mediaPath}, entrées existantes conservées`)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set(
@@ -267,7 +259,6 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
- // Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings()
let autoRemoved = 0
if (autoUnimportMissing && !mediaEmpty) {
@@ -277,7 +268,7 @@ export async function scanMediaPath(
if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId]
autoRemoved++
- logger.info('organize', `Désimport auto : fichier introuvable "${destPath}"`)
+ logger.info('organize', `Retrait automatique : fichier introuvable « ${destPath} »`)
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
@@ -288,12 +279,13 @@ export async function scanMediaPath(
writeOrganized(organized, organizedPath)
}
- logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
+ lastScan = { at: new Date().toISOString(), ...result }
+ logger.info('organize', `Analyse terminée : ${result.found} fichier(s), ${result.added} ajouté(s), ${removed} entrée(s) obsolète(s) retirée(s) du suivi${autoRemoved > 0 ? `, ${autoRemoved} retirée(s) (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} sans correspondance` : ''}`)
return result
}
-// ── Auto-import ───────────────────────────────────────────────
+// ── Import automatique ────────────────────────────────────────
export async function autoOrganizeAll(
listFn : () => Promise,
@@ -302,16 +294,16 @@ export async function autoOrganizeAll(
): Promise {
const { autoImport, mediaPath } = readSettings()
if (!autoImport) {
- logger.debug('organize', 'Import automatique désactivé — skip')
+ logger.debug('organize', 'Import automatique désactivé')
return
}
if (!mediaPath?.trim()) {
- logger.debug('organize', 'Médiathèque non configurée — skip')
+ logger.debug('organize', 'Médiathèque non configurée')
return
}
if (_queued > 0) {
- logger.debug('organize', 'Import déjà en cours — skip')
+ logger.debug('organize', 'Import déjà en cours')
return
}
@@ -350,25 +342,25 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) {
- logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
+ logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) : lancement de l'import automatique`)
for (const t of newlySeeding) {
- logger.debug('organize', `Nouveau seeding : "${t.name}"`)
+ logger.debug('organize', `Torrent terminé : « ${t.name} »`)
}
} else {
- logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
+ logger.debug('organize', `Import automatique : vérification de ${seedingCount} torrent(s) terminé(s)`)
}
try {
await enqueue(() => runWorker(torrents, seriesData, msg => {
if (msg.errors.length > 0) {
- logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
+ logger.warn('organize', `« ${msg.name} » : ${msg.done} importé(s), ${msg.skipped} ignoré(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
- logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
+ logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else if (msg.done > 0) {
- logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
+ logger.info('organize', `« ${msg.name} » : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
} else {
- logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
+ logger.debug('organize', `« ${msg.name} » : rien à importer (${msg.skipped} ignoré(s))`)
}
onResult?.({
hash : msg.hash,
@@ -380,25 +372,21 @@ export async function autoOrganizeAll(
errorFiles: msg.errors,
})
}))
- logger.debug('organize', 'Worker terminé')
+ logger.debug('organize', 'Worker d\'import terminé')
} catch (err) {
- logger.error('organize', `Worker erreur : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Erreur du worker : ${err instanceof Error ? err.message : err}`)
}
}
-// ── Migration des IDs épisodes ────────────────────────────────
-//
-// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
-// du scraper. On utilise (infohash, season_number, episode_number) comme clé
-// stable pour retrouver le nouvel ID et mettre à jour organized.json.
+// ── Migration des IDs d'épisodes ──────────────────────────────
+// Les IDs du scraper changent : on les retrouve via (infohash, saison, épisode)
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
- // 1. Construire les tables de lookup depuis le catalogue actuel
- // hashEpMap : infohash → Map<"S:E", new_episode_id>
+ // infohash : Map<"saison:épisode", ID actuel>
const hashEpMap = new Map>()
const validIds = new Set()
@@ -417,11 +405,10 @@ export async function migrateOrganizedEpisodeIds(
}
}
- // 2. Charger organized.json
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
- logger.error('organize', `Migration IDs annulée : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Migration des IDs annulée :${err instanceof Error ? err.message : err}`)
return { updated: 0, orphaned: 0 }
}
@@ -438,51 +425,45 @@ export async function migrateOrganizedEpisodeIds(
const oldId = Number(oldIdStr)
if (validIds.has(oldId)) {
- // ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry
continue
}
- // ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key)
if (newId && newId !== oldId) {
- // Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
- // Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
- logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
+ logger.info('organize', `Migration des IDs : entrée de l'épisode ${oldId} supprimée, ${newId} déjà présent (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
- logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
+ logger.info('organize', `Migration des IDs : épisode ${oldId} remplacé par ${newId} (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
}
} else {
- // Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++
newEpisodes[oldIdStr] = entry
- logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
+ logger.warn('organize', `Migration des IDs : épisode ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}…, S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
- // Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (changed) {
writeOrganized(organized, organizedPath)
- logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
+ logger.info('organize', `Migration des IDs terminée : ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolu(s)` : ''}`)
} else {
- logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
+ logger.debug('organize', `Migration des IDs : aucun changement${orphaned > 0 ? ` (${orphaned} ID(s) inconnu(s) conservé(s))` : ''}`)
}
return { updated, orphaned }
}
-// ── Déduplication ─────────────────────────────────────────────
+// ── Dédoublonnage ─────────────────────────────────────────────
export function dedupeOrganizedEpisodes(
organizedPath: string,
): { removed: number } {
@@ -493,7 +474,7 @@ export function dedupeOrganizedEpisodes(
return { removed: 0 }
}
- // episode_id → [{ hash, entry }]
+ // episode_id : [{ hash, entry }]
const byEpisode = new Map()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record)) {
@@ -513,19 +494,19 @@ export function dedupeOrganizedEpisodes(
if (present.includes(c)) continue
delete (organized[c.hash] as any)[episodeId]
removed++
- logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
+ logger.info('organize', `Dédoublonnage : épisode ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
}
}
if (removed > 0) {
writeOrganized(organized, organizedPath)
- logger.info('organize', `Dédoublonnage terminé — ${removed} entrée(s) redondante(s) supprimée(s)`)
+ logger.info('organize', `Dédoublonnage terminé : ${removed} entrée(s) redondante(s) supprimée(s)`)
}
return { removed }
}
-// ── Sync des noms de fichiers après mise à jour du scraper ────
+// ── Renommage selon le catalogue ──────────────────────────────
export async function syncFilenameChanges(
seriesData : any[],
@@ -535,7 +516,7 @@ export async function syncFilenameChanges(
let organized: Organized
try { organized = readOrganized(organizedPath) }
catch (err) {
- logger.error('organize', `Sync noms annulée : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Renommage selon le catalogue annulé :${err instanceof Error ? err.message : err}`)
return { renamed: 0, errors: 0 }
}
@@ -561,7 +542,6 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
- // Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
@@ -579,7 +559,7 @@ export async function syncFilenameChanges(
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
- logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
+ logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
continue
}
fs.renameSync(oldPath, newPath)
@@ -592,10 +572,10 @@ export async function syncFilenameChanges(
retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++
changed = true
- logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
+ logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
errors++
- logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Échec du renommage de l'épisode ${ep.id} :${err instanceof Error ? err.message : err}`)
}
}
}
@@ -605,7 +585,7 @@ export async function syncFilenameChanges(
writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0)
- logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
+ logger.info('organize', `Noms de fichiers mis à jour : ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
@@ -622,14 +602,12 @@ export async function organizeTorrent(
const key = hash.toLowerCase()
const pending = _pendingManual.get(key)
if (pending) {
- logger.info('organize', `Import manuel de "${name}" déjà en attente — demande fusionnée`)
+ logger.info('organize', `Import manuel de « ${name} » déjà en file d'attente`)
return pending
}
- logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file' : 'lancé'} pour "${name}" (${hash})`)
+ logger.info('organize', `Import manuel ${_queued > 0 ? 'mis en file d\'attente' : 'lancé'} pour « ${name} » (${hash})`)
- // files : progression par fichier récupérée depuis le client torrent.
- // Permet au worker de ne traiter que les fichiers à 100% et d'éviter
- // les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
+ // Progression par fichier : le worker ignore les fichiers incomplets (évite EBUSY sous Windows)
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
@@ -637,21 +615,22 @@ export async function organizeTorrent(
_pendingManual.delete(key)
try {
await runWorker([fakeTorrent], seriesData, msg => {
+ result.serieId = msg.serieId ?? null
result.total = msg.total
result.skipped = msg.skipped
result.done = msg.done
result.errors = msg.errors
if (msg.errors.length > 0) {
- logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
+ logger.warn('organize', `Import de « ${name} » : ${msg.done} importé(s), ${msg.errors.length} erreur(s)`)
for (const e of msg.errors) {
- logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
+ logger.error('organize', `Erreur sur « ${e.file} » : ${e.error}`)
}
} else {
- logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
+ logger.info('organize', `Import de « ${name} » terminé : ${msg.done} fichier(s) importé(s), ${msg.skipped} ignoré(s)`)
}
})
} catch (err) {
- logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err instanceof Error ? err.message : err}`)
+ logger.error('organize', `Échec de l'import de « ${name} » :${err instanceof Error ? err.message : err}`)
throw err
}
return result
diff --git a/server/requests.ts b/server/requests.ts
index 440a5d6..4e2bc25 100644
--- a/server/requests.ts
+++ b/server/requests.ts
@@ -1,6 +1,3 @@
-/**
- * Système de demandes utilisateurs
- */
import fs from 'fs'
import path from 'path'
@@ -14,8 +11,8 @@ export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
export interface Requester {
userId : string
username : string
- seasons : number[] // numéros de saison ; [] = toutes les saisons dispo
- episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé
+ seasons : number[] // numéros de saison ; [] = toutes les saisons disponibles
+ episodes : number[] // IDs d'épisodes ciblés ; [] = aucun
requestedAt: string
}
@@ -32,7 +29,7 @@ export interface SerieRequest {
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
-// ── Helpers fichier ───────────────────────────────────────────
+// ── Fichier des demandes ──────────────────────────────────────
export function readRequests(): SerieRequest[] {
try {
@@ -46,7 +43,7 @@ function writeRequests(requests: SerieRequest[]): void {
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2))
}
-/** Union des saisons demandées sur tous les requesters ([] = toutes) */
+/** Saisons demandées par l'ensemble des demandeurs ([] = toutes). */
export function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
const all = new Set()
@@ -54,33 +51,26 @@ export function mergedSeasons(req: SerieRequest): number[] {
return [...all].sort((a, b) => a - b)
}
-/** Union des IDs d'épisodes spécifiques sur tous les requesters */
export function mergedEpisodes(req: SerieRequest): number[] {
const all = new Set()
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
return [...all].sort((a, b) => a - b)
}
-// ── CRUD ──────────────────────────────────────────────────────
+// ── Création et suivi ─────────────────────────────────────────
-/**
- * Créer ou fusionner une demande.
- * - Si une demande active (non rejected/completed) existe pour la série → fusionne les saisons de cet user
- * - Sinon → crée une nouvelle demande
- */
export function upsertRequest(
userId : string,
serieId : number,
serieName: string,
seasons : number[], // [] = toutes les saisons
- episodes : number[] = [], // IDs d'épisodes spécifiques
+ episodes : number[] = [],
): SerieRequest {
const user = findById(userId)
if (!user) throw new Error('Utilisateur introuvable')
const requests = readRequests()
- // Chercher une demande active (pas rejected ni completed) pour cette série
const existing = requests.find(r =>
r.serieId === serieId &&
r.status !== 'rejected' &&
@@ -90,15 +80,13 @@ export function upsertRequest(
const now = new Date().toISOString()
if (existing) {
- // Mettre à jour ou ajouter ce requester dans la demande existante
const idx = existing.requesters.findIndex(r => r.userId === userId)
if (idx >= 0) {
const r = existing.requesters[idx]
- // "Tout" = seasons=[] ET episodes=[] (aucune granularité)
const newIsAll = seasons.length === 0 && episodes.length === 0
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
if (newIsAll || curIsAll) {
- // Dès que l'une des deux est "toutes les saisons" → effacer la granularité
+ // Si l'une couvre toute la série : plus de filtre saison ni épisode
r.seasons = []
r.episodes = []
} else {
@@ -111,11 +99,10 @@ export function upsertRequest(
}
existing.updatedAt = now
writeRequests(requests)
- logger.info('requests', `Demande mise à jour pour "${serieName}" par "${user.username}"`)
+ logger.info('requests', `Demande de « ${serieName} » complétée par « ${user.username} »`)
return existing
}
- // Nouvelle demande
const request: SerieRequest = {
id : crypto.randomUUID(),
serieId,
@@ -126,7 +113,7 @@ export function upsertRequest(
updatedAt : now,
}
writeRequests([...requests, request])
- logger.info('requests', `Nouvelle demande pour "${serieName}" par "${user.username}"`)
+ logger.info('requests', `Nouvelle demande de « ${serieName} » par « ${user.username} »`)
return request
}
@@ -138,7 +125,7 @@ export function approveRequest(id: string): SerieRequest {
req.rejectionMessage = undefined
req.updatedAt = new Date().toISOString()
writeRequests(requests)
- logger.info('requests', `Demande "${req.serieName}" approuvée`)
+ logger.info('requests', `Demande « ${req.serieName} » approuvée`)
return req
}
@@ -150,7 +137,7 @@ export function rejectRequest(id: string, message?: string): SerieRequest {
req.rejectionMessage = message
req.updatedAt = new Date().toISOString()
writeRequests(requests)
- logger.info('requests', `Demande "${req.serieName}" refusée`)
+ logger.info('requests', `Demande « ${req.serieName} » refusée`)
return req
}
@@ -170,6 +157,26 @@ export function deleteRequest(id: string): void {
writeRequests(requests.filter(r => r.id !== id))
}
+/** Retire l'utilisateur d'une demande en attente ; supprime la demande s'il était seul. */
+export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
+ const requests = readRequests()
+ const req = requests.find(r => r.id === id)
+ if (!req || !req.requesters.some(r => r.userId === userId)) throw new Error('Demande introuvable')
+ if (req.status !== 'pending') throw new Error('Seule une demande en attente peut être annulée')
+
+ const username = req.requesters.find(r => r.userId === userId)!.username
+ req.requesters = req.requesters.filter(r => r.userId !== userId)
+ if (req.requesters.length === 0) {
+ writeRequests(requests.filter(r => r.id !== id))
+ logger.info('requests', `Demande « ${req.serieName} » annulée par « ${username} »`)
+ return { deleted: true, request: null }
+ }
+ req.updatedAt = new Date().toISOString()
+ writeRequests(requests)
+ logger.info('requests', `« ${username} » retiré de la demande « ${req.serieName} »`)
+ return { deleted: false, request: req }
+}
+
export function getRequestsForUser(userId: string): SerieRequest[] {
return readRequests().filter(r => r.requesters.some(req => req.userId === userId))
}
diff --git a/server/routes/downloads.ts b/server/routes/downloads.ts
index a6ea862..de7cb20 100644
--- a/server/routes/downloads.ts
+++ b/server/routes/downloads.ts
@@ -17,7 +17,7 @@ router.post('/download', requireAuth, async (req, res) => {
const torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet
- if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
+ if (!url) { res.status(400).json({ error: 'Lien du torrent ou magnet requis' }); return }
const file_index: number | null = req.body.file_index != null ? Number(req.body.file_index) : null
const file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? null
@@ -29,13 +29,13 @@ router.post('/download', requireAuth, async (req, res) => {
router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true'
- if (!hash) { res.status(400).json({ error: 'hash requis' }); return }
+ if (!hash) { res.status(400).json({ error: 'Hash du torrent requis' }); return }
try {
const result = await dispatchRemove(hash, deleteFiles)
res.json(result)
} catch (err) {
- logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec de la suppression du torrent : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -50,6 +50,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const orgFiles = organized[t.hash] ?? {}
let totalFiles = 1
let serieName: string | undefined
+ let serieId: number | undefined
let episodes: any[] = []
try {
const availableIds = await readAvailable()
@@ -61,6 +62,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
serieName = sd.title ?? sd.show_title
+ serieId = sd.id
episodes = resolved
break
}
@@ -68,13 +70,14 @@ router.get('/downloads', requireAuth, async (_req, res) => {
} catch {}
const doneFiles = Object.keys(orgFiles).length
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
- const notif = recentOrganized.find(n => n.hash === t.hash)
- return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
+ const notif = recentOrganized.find(n => n.hash === t.hash)
+ const imported = recentOrganized.find(n => n.hash === t.hash && n.done > 0)
+ return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], importedAt: imported?.at ?? null, serieId, serieName, episodes }
}))
res.json(enriched)
} catch (err) {
- logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec du chargement des téléchargements : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/routes/import.ts b/server/routes/import.ts
index 4e5ea6a..b75ebdc 100644
--- a/server/routes/import.ts
+++ b/server/routes/import.ts
@@ -7,7 +7,7 @@ import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import {
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
- serieFolderName, seasonFolderName,
+ serieFolderName, seasonFolderName, fetchSerieFromApi,
} from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
@@ -16,7 +16,9 @@ import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type O
const router = Router()
-const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)`
+const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le catalogue Fankai. Synchronisez le catalogue ou consultez les journaux.`
+const ORGANIZED_UNREADABLE = 'Fichier de suivi des imports (organized.json) illisible'
+const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
@@ -39,8 +41,26 @@ function findOrphans(organized: Organized, catalog: any[]) {
return orphans
}
+// Épisodes publiés sur l'API mais écartés par le scraper : pas des orphelins. null si l'API ne répond pas
+async function apiOnlyEpisodes(orphans: ReturnType, catalog: any[], mediaPath: string): Promise | null> {
+ const idByFolder = new Map(catalog.map(sd => [serieFolderName(sd.title ?? sd.show_title ?? ''), sd.id]))
+ const serieIds = new Set()
+ for (const o of orphans) {
+ if (!o.dest_path || !mediaPath) continue
+ const id = idByFolder.get(path.relative(mediaPath, o.dest_path).split(path.sep)[0])
+ if (id != null) serieIds.add(id)
+ }
+ try {
+ const series = await Promise.all([...serieIds].map(fetchSerieFromApi))
+ return new Set(series.flatMap(sd => (sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).map((ep: any) => String(ep.id)))))
+ } catch (err) {
+ logger.warn('api', `Épisodes disparus du catalogue : vérification sur l'API Fankai impossible (${err instanceof Error ? err.message : err})`)
+ return null
+ }
+}
+
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
-function serieFolders(sd: any, organized: Organized, mediaPath: string): Map {
+function serieFolders(sd: any, organized: Organized, mediaPath: string, exclude = new Set()): Map {
const ids = new Set()
for (const season of sd.seasons ?? [])
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
@@ -52,12 +72,17 @@ function serieFolders(sd: any, organized: Organized, mediaPath: string): Map {
+ return new Set(catalog.filter(sd => sd.id !== serieId).map(sd => path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))))
+}
+
function listFiles(dir: string): string[] {
const files: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -78,13 +103,13 @@ function removeEmptyDirs(dir: string): void {
router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) {
- res.status(400).json({ error: 'serie_id et items requis' }); return
+ res.status(400).json({ error: 'Série ou fichiers à importer manquants' }); return
}
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
- // Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
+ // Copies asynchrones : modifications réappliquées sur le fichier relu à la fin
const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
@@ -98,11 +123,15 @@ router.post('/manual-import', requireAuth, async (req, res) => {
}
const done: number[] = []
const errors: { file: string; error: string }[] = []
+ const fail = (file: string, error: string) => {
+ errors.push({ file, error })
+ logger.error('api', `Échec de l'import manuel de « ${file} » : ${error}`)
+ }
for (const item of items) {
const { file_path, episode_id, hash } = item
- if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
+ if (!file_path || !episode_id) { fail(file_path ? path.basename(file_path) : '?', 'Paramètres manquants'); continue }
const found = episodeIndex.get(Number(episode_id))
- if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
+ if (!found) { fail(path.basename(file_path), `Épisode ${episode_id} introuvable dans la série ${serie_id}`); continue }
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
@@ -120,12 +149,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
- // Nettoyer toute entrée organized qui référence ce même fichier physique
- // sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
+ // Réassignation : retirer les entrées qui pointent ce fichier sous un autre épisode
for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id))
- logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (fichier réassigné)`)
+ logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (fichier réassigné)`)
}
}
apply(data => {
@@ -138,16 +166,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (file_path !== destPath) {
const serieRootPath = path.join(mediaPath, serieTitle)
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/')
- // Si une ancienne version de cet épisode existe à la destination (réassignation),
- // la supprimer avant le rename pour éviter un état incohérent.
if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
fs.unlinkSync(destPath)
- logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`)
- // Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
+ logger.info('api', `Import manuel (réassignation) : suppression de l'ancien fichier « ${destFilename} »`)
for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath)
- logger.info('api', `Import manuel : suppression entrée stale ep ${epId} (ancien fichier écrasé)`)
+ logger.info('api', `Import manuel : entrée obsolète de l'épisode ${epId} retirée (ancien fichier supprimé)`)
}
}
apply(data => {
@@ -159,7 +184,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath)
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
- logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
+ logger.info('api', `Import manuel : « ${srcFilename} » renommé en « ${destFilename} »`)
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
@@ -171,10 +196,10 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else {
await fs.promises.copyFile(file_path, destPath)
}
- logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
+ logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
}
} else {
- logger.debug('api', `Import manuel : "${srcFilename}" déjà en place`)
+ logger.debug('api', `Import manuel : « ${srcFilename} » déjà en place`)
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
const entry = {
@@ -185,16 +210,14 @@ router.post('/manual-import', requireAuth, async (req, res) => {
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- errors.push({ file: srcFilename, error: msg })
- logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
+ fail(srcFilename, err instanceof Error ? err.message : UNEXPECTED_ERROR)
}
}
if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) {
- logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
+ logger.error('api', `Import manuel : échec de l'enregistrement (${err instanceof Error ? err.message : err})`)
+ res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
}
@@ -217,9 +240,9 @@ router.post('/manual-import', requireAuth, async (req, res) => {
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer()))
}
- logger.info('api', `NFO téléchargés pour "${gitlabTitle}" (import manuel)`)
+ logger.info('api', `NFO téléchargés pour « ${gitlabTitle} » (import manuel)`)
} catch (err) {
- logger.warn('api', `Échec téléchargement NFO pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Échec du téléchargement des NFO de « ${gitlabTitle} » :${err instanceof Error ? err.message : err}`)
}
})()
}
@@ -227,7 +250,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
-// ── Récap organisés d'une série ────────────────────────────────
+// ── Épisodes importés d'une série ──────────────────────────────
router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
@@ -261,20 +284,20 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
}
res.json(result)
} catch (err) {
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
-// ── Rename épisode ─────────────────────────────────────────────
+// ── Renommage d'un épisode ─────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body
- if (!serie_id || !episode_id) { res.status(400).json({ error: 'serie_id et episode_id requis' }); return }
+ if (!serie_id || !episode_id) { res.status(400).json({ error: 'Série ou épisode manquant' }); return }
const { nfoSupport } = readSettings()
const sd = await resolveSerieData(Number(serie_id), 'Renommage')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
let foundEp: any = null
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -297,30 +320,30 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
- if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
- if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
+ if (!fs.existsSync(oldPath)) throw new Error('Fichier introuvable sur le disque')
+ if (fs.existsSync(newPath)) throw new Error(`Un fichier nommé « ${newName} » existe déjà`)
fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
writeOrganized(organized)
- logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
+ logger.info('api', `Renommage : « ${orgEntry.dest_filename} » en « ${newName} »`)
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`)
+ const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
+ logger.error('api', `Échec du renommage de l'épisode ${episode_id} : ${msg}`)
res.status(500).json({ error: msg })
}
})
-// ── Désimport série complète ───────────────────────────────────
+// ── Retrait d'une série ────────────────────────────────────────
router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true'
- const sd = await resolveSerieData(Number(serieId), 'Désimport série')
+ const sd = await resolveSerieData(Number(serieId), 'Retrait de la série')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const episodeIds = new Set()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -351,38 +374,36 @@ router.delete('/organized/:serieId', requireAuth, async (req, res) => {
if (hash === 'manual') continue
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
- // Supprimer le dossier de la série s'il existe encore
if (serieFolder && fs.existsSync(serieFolder)) {
try {
fs.rmSync(serieFolder, { recursive: true, force: true })
- logger.info('api', `Dossier série supprimé : ${serieFolder}`)
+ logger.info('api', `Dossier de la série supprimé : ${serieFolder}`)
} catch (err) {
- logger.warn('api', `Impossible de supprimer le dossier série "${serieFolder}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Impossible de supprimer le dossier de la série « ${serieFolder} » :${err instanceof Error ? err.message : err}`)
}
}
}
- // Supprimer les demandes "disponible" liées à cette série
const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
for (const r of completedRequests) {
try { deleteRequest(r.id) } catch {}
}
if (completedRequests.length > 0)
- logger.info('api', `Désimport série ${serieId} — ${completedRequests.length} demande(s) "disponible" supprimée(s)`)
+ logger.info('api', `Retrait de la série ${serieId} : ${completedRequests.length} demande(s) « disponible » supprimée(s)`)
- logger.info('api', `Désimport série ${serieId} — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
+ logger.info('api', `Retrait de la série ${serieId} : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
-// ── Désimport saison ───────────────────────────────────────────
+// ── Retrait d'une saison ───────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true'
- const sd = await resolveSerieData(Number(serieId), 'Désimport saison')
+ const sd = await resolveSerieData(Number(serieId), 'Retrait de la saison')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set(season.episodes?.map((e: any) => String(e.id)) ?? [])
@@ -408,17 +429,17 @@ router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req,
dispatchRemove(hash, false).catch(err => logger.warn('api', `Impossible de retirer le torrent ${hash.slice(0, 8)}… du client : ${err instanceof Error ? err.message : err}`))
}
}
- logger.info('api', `Désimport saison ${seasonId} (série ${serieId}) — ${removed} épisode(s) retirés${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retirés du client` : ''}`)
+ logger.info('api', `Retrait de la saison ${seasonId} (série ${serieId}) : ${removed} épisode(s) retiré(s)${deleteFile && emptyHashes.length ? `, ${emptyHashes.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true, removed, errors })
})
-// ── Désimport épisode ──────────────────────────────────────────
+// ── Retrait d'un épisode ───────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true'
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] })
@@ -432,7 +453,7 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p)
deletedPaths.add(p)
- logger.info('api', `Désimport + suppression fichier : "${p}"`)
+ logger.info('api', `Retrait et suppression du fichier « ${p} »`)
}
}
const emptyHashes: string[] = []
@@ -441,7 +462,6 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
}
writeOrganized(organized)
- // Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
if (deleteFile) {
for (const hash of emptyHashes) {
if (hash === 'manual') continue
@@ -449,16 +469,16 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
}
}
const removedTorrents = deleteFile ? emptyHashes.filter(h => h !== 'manual') : []
- logger.info('api', `Désimport ep ${episodeId}${removedTorrents.length ? ` + ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
+ logger.info('api', `Retrait de l'épisode ${episodeId}${removedTorrents.length ? `, ${removedTorrents.length} torrent(s) retiré(s) du client` : ''}`)
res.json({ ok: true })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('api', `Désimport échoué : ${msg}`)
+ const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
+ logger.error('api', `Échec du retrait de l'épisode ${episodeId} : ${msg}`)
res.status(500).json({ error: msg })
}
})
-// ── Récap global des imports ───────────────────────────────────
+// ── Récapitulatif des imports ──────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => {
try {
const { nfoSupport } = readSettings()
@@ -473,8 +493,7 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
- // Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
- // pour garantir le bon hash → bon formatted_name dans resolveEpNaming
+ // Chercher d'abord via ep.paths : le hash détermine le formatted_name
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
@@ -498,39 +517,69 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
}
}
- const orphans = complete
- ? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
- : []
- res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
+ const candidates = complete ? findOrphans(organized, seriesData) : []
+ const apiOnly = candidates.length > 0 ? await apiOnlyEpisodes(candidates, seriesData, readSettings().mediaPath) : new Set()
+ const orphans = (apiOnly ? candidates : [])
+ .filter(o => !apiOnly?.has(String(o.episode_id)))
+ .map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
+ res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete && !!apiOnly })
} catch (err) {
- logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec du récapitulatif des imports : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
-// ── Retrait des entrées orphelines ─────────────────────────────
+// ── Retrait des épisodes disparus du catalogue ─────────────────
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
const { series, complete } = await loadCatalogStatus()
- if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return }
+ const catalogIncomplete = 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard'
+ if (!complete) { res.status(503).json({ error: catalogIncomplete }); return }
+ let apiOnly: Set | null
+ try { apiOnly = await apiOnlyEpisodes(findOrphans(readOrganized(), series), series, readSettings().mediaPath) }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
+ if (!apiOnly) { res.status(503).json({ error: catalogIncomplete }); return }
let removed = 0
try {
updateOrganized(data => {
for (const o of findOrphans(data, series)) {
if (only && !only.has(String(o.episode_id))) continue
+ if (apiOnly.has(String(o.episode_id))) continue
delete data[o.hash][String(o.episode_id)]
removed++
}
return removed > 0
})
} catch (err) {
- res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
+ res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return
}
- logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`)
+ logger.info('api', `Épisodes disparus du catalogue retirés du suivi : ${removed}`)
res.json({ ok: true, removed })
})
-// ── Dossier série ──────────────────────────────────────────────
+// ── Dossier de série ───────────────────────────────────────────
+router.get('/organized-folders', requireAdmin, async (_req, res) => {
+ const { mediaPath } = readSettings()
+ if (!mediaPath) { res.json([]); return }
+ let organized: Organized
+ try { organized = readOrganized() }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
+ try {
+ const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
+ const catalog = await loadCatalog()
+ for (const sd of catalog) {
+ const title = sd.title ?? sd.show_title ?? ''
+ const expected = path.join(mediaPath, serieFolderName(title))
+ const stale = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, sd.id, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
+ if (stale.length > 0) result.push({ serie_id: sd.id, serie_title: title, expected, current: stale })
+ }
+ res.json(result)
+ } catch (err) {
+ logger.error('api', `Échec de la vérification des dossiers de séries : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
+ }
+})
+
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
const sd = await resolveSerieData(serieId)
@@ -538,23 +587,27 @@ router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
const { mediaPath } = readSettings()
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
- const current = [...serieFolders(sd, organized, mediaPath)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
+ const others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath)
+ const current = [...serieFolders(sd, organized, mediaPath, others)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
})
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const serieId = Number(req.params.serieId)
- const sd = await resolveSerieData(serieId, 'Renommage dossier')
+ const sd = await resolveSerieData(serieId, 'Renommage du dossier')
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings()
- if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return }
+ if (!mediaPath) { res.status(400).json({ error: 'Chemin de la médiathèque non configuré' }); return }
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
+ let catalog: any[]
+ try { catalog = await loadCatalog() }
+ catch (err) { res.status(503).json({ error: `Catalogue indisponible : ${err instanceof Error ? err.message : err}` }); return }
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
- const sources = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
+ const sources = [...serieFolders(sd, organized, mediaPath, otherSerieFolders(catalog, serieId, mediaPath)).keys()].filter(f => f !== expected && fs.existsSync(f))
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
@@ -564,7 +617,7 @@ router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const targets = [...moves.values()]
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
if (conflicts.length > 0) {
- res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
+ res.status(409).json({ error: `${conflicts.length} fichier(s) existe(nt) déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
}
const done = new Map()
@@ -585,19 +638,19 @@ router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
let updated = 0
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
catch (err) {
- logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return
+ logger.error('api', `Renommage du dossier de la série ${serieId} : fichiers déplacés mais suivi non mis à jour (${err instanceof Error ? err.message : err})`)
+ res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez une analyse de la médiathèque' }); return
}
- const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"`
+ const label = `« ${sources.map(s => path.basename(s)).join(' », « ')} » en « ${path.basename(expected)} »`
if (failure) {
- logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
- res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
+ logger.error('api', `Renommage du dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
+ res.status(500).json({ error: `Déplacement interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
}
- logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
+ logger.info('api', `Dossier de série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
res.json({ ok: true, moved: done.size, updated })
})
-// ── Rename en masse ────────────────────────────────────────────
+// ── Renommage groupé ───────────────────────────────────────────
router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id, serie_ids } = req.body
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
@@ -605,7 +658,7 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
const seriesData = await loadCatalog()
let organized: Organized
try { organized = readOrganized() }
- catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
+ catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : ORGANIZED_UNREADABLE }); return }
const done: number[] = []
const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) {
@@ -624,23 +677,23 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) {
- const msg = `Fichier introuvable : "${oldPath}"`
- logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
+ const msg = `Fichier introuvable : « ${oldPath} »`
+ logger.error('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
- const msg = `Fichier existant : "${expectedName}"`
- logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
+ const msg = `Un fichier nommé « ${expectedName} » existe déjà`
+ logger.warn('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]]))
done.push(ep.id)
- logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
+ logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
+ const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
+ logger.error('api', `Échec du renommage de l'épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
@@ -650,11 +703,11 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
-// ── Purge NFO / images des dossiers série ─────────────────────
+// ── Purge des NFO et images ───────────────────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
- res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
+ res.status(400).json({ error: 'Chemin de la médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
@@ -672,10 +725,10 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
try {
fs.unlinkSync(full)
deleted++
- logger.debug('api', `Purge NFO : supprimé "${full}"`)
+ logger.debug('api', `Purge NFO : « ${full} » supprimé`)
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.error('api', `Purge NFO : impossible de supprimer « ${full} » : ${msg}`)
errors.push(full)
}
}
@@ -683,7 +736,7 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
}
walk(mediaPath)
- logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
+ logger.info('api', `Purge NFO : ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
diff --git a/server/routes/invites.ts b/server/routes/invites.ts
index 51bb3b3..01db91d 100644
--- a/server/routes/invites.ts
+++ b/server/routes/invites.ts
@@ -20,12 +20,12 @@ router.post('/invites', requireAdmin, (req, res) => {
const invite = createInvite({
createdBy : req.user!.id,
expiresInHours: expiresInHours ?? null,
- maxUses : maxUses ?? 1,
+ maxUses,
note,
})
res.json(invite)
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -35,11 +35,11 @@ router.delete('/invites/:code', requireAdmin, (req, res) => {
deleteInvite(String(req.params.code))
res.json({ success: true })
} catch (err) {
- res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-// GET /api/invites/:code/info (public — pour afficher la page d'inscription)
+// GET /api/invites/:code/info (public : page d'inscription)
router.get('/invites/:code/info', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
@@ -48,7 +48,7 @@ router.get('/invites/:code/info', (req, res) => {
res.json({ valid: true, note: result.invite.note ?? null })
})
-// POST /api/invites/:code/register (public — inscription via invitation)
+// POST /api/invites/:code/register (public : inscription par invitation)
router.post('/invites/:code/register', (req, res) => {
const result = validateInvite(req.params.code)
if (!result.valid) {
@@ -57,10 +57,10 @@ router.post('/invites/:code/register', (req, res) => {
const { username, password } = req.body
if (!username || !password) {
- res.status(400).json({ error: 'username et password requis' }); return
+ res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
- res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
+ res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
@@ -68,7 +68,7 @@ router.post('/invites/:code/register', (req, res) => {
consumeInvite(req.params.code)
res.json({ success: true, user: safeUser(user) })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/routes/jellyfin.ts b/server/routes/jellyfin.ts
index 6a8602d..f0f046a 100644
--- a/server/routes/jellyfin.ts
+++ b/server/routes/jellyfin.ts
@@ -6,17 +6,15 @@ import { logger } from '../logger.js'
const router = Router()
-// GET /api/jellyfin/settings
router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({
jellyfinUrl,
- // Ne jamais retourner le token en clair — juste indiquer s'il est configuré
+ // Clé API jamais renvoyée : seulement sa présence
hasToken: !!jellyfinAdminToken,
})
})
-// POST /api/jellyfin/settings
router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record = {}
@@ -26,11 +24,10 @@ router.post('/jellyfin/settings', (req, res) => {
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
})
-// POST /api/jellyfin/test
router.post('/jellyfin/test', async (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) {
- res.status(400).json({ ok: false, error: 'URL et token requis' }); return
+ res.status(400).json({ ok: false, error: 'URL et clé API requises' }); return
}
const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken)
res.json(result)
@@ -57,20 +54,19 @@ export async function runJellyfinSync(): Promise<{ created: number; skipped: num
createUser(jUser.Name, randomPass, 'user')
results.created++
results.users.push(jUser.Name)
- logger.info('jellyfin', `Compte créé pour "${jUser.Name}" (sync Jellyfin)`)
+ logger.info('jellyfin', `Compte créé pour « ${jUser.Name} » (synchronisation Jellyfin)`)
}
- logger.info('jellyfin', `Sync terminée — ${results.created} créés, ${results.skipped} ignorés`)
+ logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`)
return results
}
-// POST /api/jellyfin/sync
router.post('/jellyfin/sync', async (_req, res) => {
try {
const results = await runJellyfinSync()
res.json(results)
} catch (err) {
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur sync Jellyfin' })
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' })
}
})
diff --git a/server/routes/nfo-updates.ts b/server/routes/nfo-updates.ts
index 8f2f5d1..00a979b 100644
--- a/server/routes/nfo-updates.ts
+++ b/server/routes/nfo-updates.ts
@@ -10,8 +10,8 @@ router.get('/nfo-updates/recent', requireAuth, (_req, res) => {
})
router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
- logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
- checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
+ logger.info('nfo-update', 'Vérification manuelle des mises à jour NFO lancée')
+ checkNfoUpdates().catch(err => logger.error('nfo-update', `Échec de la vérification manuelle des mises à jour NFO : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
diff --git a/server/routes/organize.ts b/server/routes/organize.ts
index 23fe835..3871fe7 100644
--- a/server/routes/organize.ts
+++ b/server/routes/organize.ts
@@ -3,8 +3,10 @@ import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadCatalog } from '../lib/serie-helpers.js'
-import { recentOrganized, pushNotif } from '../lib/notifs.js'
+import { recentOrganized, pushNotif, clearNotifs } from '../lib/notifs.js'
import { dispatchGetFiles } from '../torrent-clients/index.js'
+import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule } from '../lib/schedule.js'
+import { readSettings } from '../settings.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
@@ -17,40 +19,42 @@ router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
- logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec de la migration des IDs : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
- res.json(recentOrganized)
+ res.json(recentOrganized.slice(0, 20))
+})
+
+router.get('/organize/schedule', requireAuth, (_req, res) => {
+ res.json({ autoImport: readSettings().autoImport, intervalMs: AUTO_IMPORT_INTERVAL_MS, ...autoImportSchedule })
})
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
- recentOrganized.length = 0
+ clearNotifs()
logger.info('api', 'Historique des imports effacé')
res.json({ ok: true })
})
router.post('/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 }
+ if (!hash || !name || !save_path) { res.status(400).json({ error: 'Hash, nom et dossier du torrent requis' }); return }
try {
const seriesData = await loadCatalog()
- // Récupère la progression par fichier depuis le client torrent.
- // Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
- // En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
+ // Progression par fichier, facultative (client injoignable toléré)
const files = await dispatchGetFiles(hash).catch(() => [])
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
if (result.done > 0 || result.errors.length > 0) {
- pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
+ pushNotif({ hash, name, serieId: result.serieId ?? null, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
}
res.json(result)
} catch (err) {
- logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec de l'import manuel de « ${name} » : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/routes/plex.ts b/server/routes/plex.ts
index 290ff0b..73abae1 100644
--- a/server/routes/plex.ts
+++ b/server/routes/plex.ts
@@ -7,7 +7,7 @@ const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
-// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
+// ── Serveur Plex local : certificat non vérifié ───────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
@@ -16,7 +16,6 @@ function getServerFetch(serverUrl: string) {
})
}
-// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise {
const res = await fetch(url, {
...options,
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise {
...(options.headers ?? {}),
},
})
- if (!res.ok) throw new Error(`Plex ${res.status}: ${await res.text()}`)
+ if (!res.ok) {
+ logger.debug('plex', `plex.tv HTTP ${res.status} : ${await res.text().catch(() => '')}`)
+ throw new Error(`Plex a répondu HTTP ${res.status}`)
+ }
const text = await res.text()
return text ? JSON.parse(text) : {}
}
@@ -43,7 +45,6 @@ function mapServers(resources: any[]) {
}))
}
-// POST /api/plex/connect — auth plex.tv + liste serveurs
router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return }
@@ -56,23 +57,22 @@ router.post('/plex/connect', requireAuth, async (req, res) => {
body : params.toString(),
})
const token = authData.authToken ?? authData.user?.authToken
- if (!token) throw new Error('Token non reçu')
+ if (!token) throw new Error('Plex n\'a pas renvoyé de jeton d\'accès')
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
- logger.info('plex', `Auth réussie pour ${username} — ${servers.length} serveur(s)`)
+ logger.info('plex', `Connexion Plex réussie pour ${username} (${servers.length} serveur(s))`)
res.json({ ok: true, token, servers })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
if (msg.includes('401')) {
- res.status(401).json({ error: '2FA requis ou identifiants incorrects', requires2FA: true })
+ res.status(401).json({ error: 'Identifiants incorrects, ou code de validation en deux étapes requis', requires2FA: true })
} else {
- logger.error('plex', `Auth échouée : ${msg}`)
- res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' })
+ logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
+ res.status(401).json({ error: 'Échec de l\'authentification : vérifiez vos identifiants' })
}
}
})
-// POST /api/plex/oauth/start — démarre le flow OAuth
router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, {
@@ -82,18 +82,17 @@ router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
})
const pinId = data.id
const pinCode = data.code
- if (!pinId || !pinCode) throw new Error('Pin non reçu')
+ if (!pinId || !pinCode) throw new Error('Plex n\'a pas renvoyé de code PIN')
const authUrl = `https://app.plex.tv/auth#?clientID=fankarr&code=${pinCode}&context%5Bdevice%5D%5Bproduct%5D=FanKarr&forwardUrl=https%3A%2F%2Fplex.tv`
- logger.info('plex', `OAuth démarré — pinId ${pinId}`)
+ logger.info('plex', `Connexion Plex démarrée (PIN ${pinId})`)
res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur'
- logger.error('plex', `OAuth start échoué : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.error('plex', `Échec du démarrage de la connexion Plex : ${msg}`)
res.status(500).json({ error: msg })
}
})
-// GET /api/plex/oauth/poll/:pinId — vérifie si l'OAuth est complété
router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId)
try {
@@ -102,22 +101,20 @@ router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
if (!token) { res.json({ ok: false, pending: true }); return }
const resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources)
- logger.info('plex', `OAuth complété — ${servers.length} serveur(s)`)
+ logger.info('plex', `Connexion Plex réussie (${servers.length} serveur(s))`)
res.json({ ok: true, pending: false, token, servers })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur'
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
res.status(500).json({ error: msg })
}
})
-// POST /api/plex/setup — enregistre l'agent Fankai + crée la bibliothèque
router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) {
- res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
+ res.status(400).json({ error: 'Jeton Plex, serveur, nom et dossier de la bibliothèque requis' }); return
}
- // Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record = { 'X-Plex-Token': token, 'Accept': 'application/json' }
@@ -127,7 +124,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
let groupId: string | null = null
let agentSetupOk = false
- // ── Étape 1 : Enregistrer le provider Fankai ─────────────
+ // ── Étape 1 : enregistrer l'agent Fankai ─────────────────
try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
@@ -140,7 +137,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
- steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
+ steps.push({ step: 'provider', ok: true, message: `Agent Fankai enregistré (${agentIdentifier})` })
}
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
@@ -156,22 +153,22 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
if (group) {
groupId = String(group.id ?? '')
- steps.push({ step: 'group', ok: true, message: `Groupe d'agents créé (ID: ${groupId})` })
+ steps.push({ step: 'group', ok: true, message: `Groupe d'agents prêt (ID : ${groupId})` })
agentSetupOk = true
}
}
} else {
const status = providersRes.status
- steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
- logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
+ steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré (HTTP ${status}, Plex antérieur à 1.43 ?) : configuration manuelle nécessaire` })
+ logger.warn('plex', `Configuration de l'agent Fankai : HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur'
- steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
- logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré : configuration manuelle nécessaire` })
+ logger.warn('plex', `Échec de la configuration de l'agent Fankai : ${msg}`)
}
- // ── Étape 2 : Créer la bibliothèque ──────────────────────
+ // ── Étape 2 : créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({
type : 'show',
@@ -183,14 +180,17 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
- if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
- steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
- logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
+ if (!libRes.ok) {
+ logger.debug('plex', `Création de bibliothèque HTTP ${libRes.status} : ${await libRes.text().catch(() => '')}`)
+ throw new Error(`le serveur Plex a répondu HTTP ${libRes.status}`)
+ }
+ steps.push({ step: 'library', ok: true, message: `Bibliothèque « ${libraryName} » créée` })
+ logger.info('plex', `Bibliothèque « ${libraryName} » créée sur ${serverUrl}`)
res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur'
- steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` })
- logger.error('plex', `Création bibliothèque échouée : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ steps.push({ step: 'library', ok: false, message: `Échec de la création de la bibliothèque : ${msg}` })
+ logger.error('plex', `Échec de la création de la bibliothèque : ${msg}`)
res.status(500).json({ ok: false, steps, error: msg })
}
})
diff --git a/server/routes/public-api.ts b/server/routes/public-api.ts
index b7e352d..899a45d 100644
--- a/server/routes/public-api.ts
+++ b/server/routes/public-api.ts
@@ -1,7 +1,3 @@
-/**
- * API publique v1 — pour le plugin Jellyfin et intégrations tierces
- * Auth : Bearer {apiToken} dans le header Authorization
- */
import { Router } from 'express'
import { findByApiToken } from '../users.js'
@@ -17,24 +13,23 @@ import type { Request, Response, NextFunction } from 'express'
const router = Router()
-// ── Middleware auth API token ─────────────────────────────────
+// ── Jeton d'API ───────────────────────────────────────────────
function requireApiToken(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
- res.status(401).json({ error: 'Token requis (Authorization: Bearer )' }); return
+ res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer )' }); return
}
const user = findByApiToken(header.slice(7))
if (!user) {
- res.status(401).json({ error: 'Token invalide' }); return
+ res.status(401).json({ error: 'Jeton d\'API invalide' }); return
}
req.user = user
next()
}
// ── POST /api/v1/auth/jellyfin ────────────────────────────────
-// Échange un token Jellyfin contre un token FanKarr
-// Body: { jellyfinUserId, jellyfinToken }
+// Échange un jeton Jellyfin contre un jeton FanKarr
router.post('/v1/auth/jellyfin', async (req, res) => {
const { jellyfinUserId, jellyfinToken } = req.body
if (!jellyfinUserId || !jellyfinToken) {
@@ -43,20 +38,19 @@ router.post('/v1/auth/jellyfin', async (req, res) => {
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
if (!info.valid) {
- res.status(401).json({ error: info.error ?? 'Authentification Jellyfin échouée' }); return
+ res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
}
- // Trouver l'utilisateur FanKarr correspondant (par username)
const users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
if (!fanUser) {
- logger.warn('public-api', `SSO Jellyfin : aucun compte FanKarr pour "${info.username}" — sync requise`)
+ logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`)
res.status(404).json({
- error: `Aucun compte FanKarr pour "${info.username}". Demandez à l'admin de lancer la synchronisation Jellyfin.`,
+ error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`,
}); return
}
- logger.info('public-api', `SSO Jellyfin réussi pour "${fanUser.username}"`)
+ logger.info('public-api', `Connexion via Jellyfin réussie pour « ${fanUser.username} »`)
res.json({
token : fanUser.apiToken,
username: fanUser.username,
@@ -70,7 +64,6 @@ router.get('/v1/auth/me', requireApiToken, (req, res) => {
})
// ── GET /api/v1/series/search?q= ─────────────────────────────
-// Recherche dans le catalogue FanKai
router.get('/v1/series/search', requireApiToken, async (req, res) => {
const q = String(req.query.q ?? '').trim().toLowerCase()
try {
@@ -113,15 +106,15 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
res.json(results)
} catch (err) {
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/series/:id ────────────────────────────────────
-// Détail d'une série : saisons + épisodes (proxy FanKai)
+// Relais de l'API Fankai
router.get('/v1/series/:id', requireApiToken, async (req, res) => {
const id = Number(req.params.id)
- if (isNaN(id)) { res.status(400).json({ error: 'id invalide' }); return }
+ if (isNaN(id)) { res.status(400).json({ error: 'Identifiant de série invalide' }); return }
try {
const [serieRaw, seasonsData] = await Promise.all([
fankaiGet(`/series/${id}`),
@@ -156,12 +149,11 @@ router.get('/v1/series/:id', requireApiToken, async (req, res) => {
seasons : seasonsWithEpisodes,
})
} catch (err) {
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── POST /api/v1/requests ─────────────────────────────────────
-// Créer ou mettre à jour une demande
router.post('/v1/requests', requireApiToken, (req, res) => {
const { serieId, serieName, seasons, episodes } = req.body
if (!serieId || !serieName) {
@@ -179,7 +171,6 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
submittedEpisodes,
)
- // Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
@@ -190,18 +181,17 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
if (isNew) request = approveRequest(request.id)
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
- logger.warn('public-api', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('public-api', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
res.json(request)
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// ── GET /api/v1/requests ──────────────────────────────────────
-// Demandes de l'utilisateur authentifié
router.get('/v1/requests', requireApiToken, (req, res) => {
res.json(getRequestsForUser(req.user!.id))
})
diff --git a/server/routes/requests.ts b/server/routes/requests.ts
index 4f96ce9..fcfb6af 100644
--- a/server/routes/requests.ts
+++ b/server/routes/requests.ts
@@ -2,26 +2,40 @@ import { Router } from 'express'
import { requireAdmin } from '../auth.js'
import {
readRequests, upsertRequest, approveRequest,
- rejectRequest, completeRequest, deleteRequest,
+ rejectRequest, completeRequest, deleteRequest, withdrawRequest,
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
type SerieRequest,
} from '../requests.js'
import { dispatchDownload } from '../torrent-clients/index.js'
import { readSerieData } from '../lib/github-cache.js'
+import { resolveRequestTorrents, countRequestedEpisodes } from '../lib/request-scope.js'
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
const router = Router()
-// GET /api/requests — admin voit tout, user voit les siennes
-router.get('/requests', (req, res) => {
+router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
: getRequestsForUser(req.user!.id)
- res.json(list)
+ res.json(await withScope(list))
})
-// GET /api/requests/pending-count — pour le widget dashboard
+async function withScope(list: SerieRequest[]) {
+ const serieData = new Map>()
+ return Promise.all(list.map(async r => {
+ if (!serieData.has(r.serieId)) serieData.set(r.serieId, readSerieData(r.serieId))
+ const sd = await serieData.get(r.serieId)
+ const seasons = mergedSeasons(r)
+ const episodes = mergedEpisodes(r)
+ return {
+ ...r,
+ episodeCount: sd ? countRequestedEpisodes(sd, seasons, episodes) : null,
+ hasTorrents : !!sd && resolveRequestTorrents(sd, seasons, episodes).length > 0,
+ }
+ }))
+}
+
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
? getPendingCount()
@@ -29,11 +43,10 @@ router.get('/requests/pending-count', (req, res) => {
res.json({ count })
})
-// POST /api/requests — créer ou mettre à jour une demande
router.post('/requests', (req, res) => {
const { serieId, serieName, seasons, episodes, torrentOverride } = req.body
if (!serieId || !serieName) {
- res.status(400).json({ error: 'serieId et serieName requis' }); return
+ res.status(400).json({ error: 'Série manquante dans la demande' }); return
}
try {
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
@@ -47,7 +60,6 @@ router.post('/requests', (req, res) => {
submittedEpisodes,
)
- // Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all'
@@ -58,7 +70,6 @@ router.post('/requests', (req, res) => {
if (isNew) request = approveRequest(request.id)
if (torrentOverride?.torrent_url || torrentOverride?.magnet) {
- // L'utilisateur a sélectionné un torrent spécifique → l'envoyer directement
const url = torrentOverride.torrent_url ?? torrentOverride.magnet
dispatchDownload(url, {
infohash : torrentOverride.infohash ?? null,
@@ -66,24 +77,22 @@ router.post('/requests', (req, res) => {
file_index: torrentOverride.file_index ?? null,
file_path : torrentOverride.file_path ?? null,
}).catch(err =>
- logger.warn('requests', `Auto-dl (override) échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('requests', `Échec du téléchargement automatique du torrent choisi pour « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else {
- // Pas de torrent sélectionné → résolution automatique
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err =>
- logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
}
}
res.json(request)
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-// PATCH /api/requests/:id — admin : approve / reject / complete
router.patch('/requests/:id', requireAdmin, async (req, res) => {
const { action, rejectionMessage } = req.body
try {
@@ -91,106 +100,33 @@ router.patch('/requests/:id', requireAdmin, async (req, res) => {
const id = String(req.params.id)
if (action === 'approve') {
request = approveRequest(id)
- // Approbation manuelle par l'admin → toujours lancer le téléchargement
autoDownloadRequest(request).catch(err =>
- logger.warn('requests', `Auto-dl échoué pour "${request.serieName}" : ${err instanceof Error ? err.message : err}`)
+ logger.warn('requests', `Échec du téléchargement automatique de « ${request.serieName} » : ${err instanceof Error ? err.message : err}`)
)
} else if (action === 'reject') request = rejectRequest(id, rejectionMessage)
else if (action === 'complete') request = completeRequest(id)
- else { res.status(400).json({ error: 'Action invalide (approve | reject | complete)' }); return }
+ else { res.status(400).json({ error: 'Action inconnue (approve, reject ou complete)' }); return }
res.json(request)
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-/**
- * Trouve et envoie au client torrent les fichiers correspondant à la demande.
- * @param override - si fourni, utilise ces saisons/épisodes au lieu du merged de la demande
- * (utile pour ne télécharger que ce qui vient d'être ajouté)
- */
+/** Envoie les torrents de la demande ; override limite aux saisons et épisodes qui viennent d'être ajoutés. */
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise {
const serieData = await readSerieData(req.serieId)
if (!serieData) {
- logger.warn('requests', `Auto-dl : aucune donnée torrent pour série ${req.serieId}`)
+ logger.warn('requests', `Téléchargement automatique : aucune donnée de torrent pour la série ${req.serieId}`)
return
}
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
- type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
- const toDownload: TorrentEntry[] = []
- const seenHashes = new Set()
-
- // Tous les torrents pack (série + saisons) pour résoudre les ep.paths
- const allPackTorrents: any[] = [
- ...(serieData.torrents ?? []),
- ...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
- ]
-
- function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
- const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
- if (seenHashes.has(key)) return
- seenHashes.add(key)
- toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
- }
-
- /**
- * Ajoute le premier torrent disponible par épisode (individuel ou via file_index dans un pack).
- * On prend le premier uniquement pour éviter les doublons quand plusieurs sources existent.
- */
- function addEpisodesByIndex(epList: any[]) {
- for (const ep of epList) {
- if (ep.torrents?.length > 0) {
- // Premier torrent individuel disponible
- addTorrent(ep.torrents[0])
- } else if (ep.paths?.length > 0) {
- // Premier path dans un pack
- const p = ep.paths[0]
- const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
- if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
- }
- }
- }
-
- if (episodes.length > 0) {
- // ── Épisodes ciblés ───────────────────────────────────────
- for (const season of serieData.seasons ?? []) {
- const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
- addEpisodesByIndex(targets)
- }
- } else if (seasons.length > 0) {
- // ── Saisons ciblées ───────────────────────────────────────
- for (const season of serieData.seasons ?? []) {
- if (!seasons.includes(season.season_number)) continue
- if ((season.torrents ?? []).length > 0) {
- // Premier pack saison disponible → télécharger en bloc
- addTorrent(season.torrents[0])
- } else {
- // Pas de pack → épisode par épisode avec file_index
- addEpisodesByIndex(season.episodes ?? [])
- }
- }
- } else {
- // ── Toutes les saisons : intégrale en priorité ────────────
- if ((serieData.torrents ?? []).length > 0) {
- addTorrent(serieData.torrents[0])
- } else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
- // Packs saison (premier par saison)
- for (const season of serieData.seasons ?? []) {
- if (season.torrents?.length > 0) addTorrent(season.torrents[0])
- }
- } else {
- // Fallback : épisode par épisode avec file_index
- for (const season of serieData.seasons ?? []) {
- addEpisodesByIndex(season.episodes ?? [])
- }
- }
- }
+ const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
if (toDownload.length === 0) {
- logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`)
+ logger.warn('requests', `Téléchargement automatique : aucun torrent trouvé pour « ${req.serieName} »`)
return
}
@@ -199,16 +135,15 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const url = t.url ?? t.magnet
if (!url) continue
try {
- await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
- sent++
+ const results = await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null })
+ if (results.some(r => r.ok)) sent++
} catch (err) {
- logger.warn('requests', `Auto-dl erreur torrent : ${err instanceof Error ? err.message : err}`)
+ logger.warn('requests', `Téléchargement automatique : erreur à l'envoi d'un torrent : ${err instanceof Error ? err.message : err}`)
}
}
- logger.info('requests', `Auto-dl "${req.serieName}" — ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
+ logger.info('requests', `Téléchargement automatique de « ${req.serieName} » : ${sent}/${toDownload.length} torrent(s) envoyé(s)`)
}
-// DELETE /api/requests — admin : supprimer toutes les demandes
router.delete('/requests', requireAdmin, (_req, res) => {
const all = readRequests()
for (const r of all) {
@@ -218,13 +153,20 @@ router.delete('/requests', requireAdmin, (_req, res) => {
res.json({ deleted: all.length })
})
-// DELETE /api/requests/:id — admin seulement
+router.delete('/requests/:id/mine', (req, res) => {
+ try {
+ res.json(withdrawRequest(String(req.params.id), req.user!.id))
+ } catch (err) {
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
+ }
+})
+
router.delete('/requests/:id', requireAdmin, (req, res) => {
try {
deleteRequest(String(req.params.id))
res.json({ success: true })
} catch (err) {
- res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(404).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/routes/rss-sync.ts b/server/routes/rss-sync.ts
index a086b74..f885167 100644
--- a/server/routes/rss-sync.ts
+++ b/server/routes/rss-sync.ts
@@ -6,57 +6,51 @@ import { loadCatalogStatus } from '../lib/serie-helpers.js'
const router = Router()
-/** GET /api/rss-sync → liste des séries surveillées */
router.get('/rss-sync', requireAuth, (_req, res) => {
res.json(Object.values(loadSynced()))
})
-/** POST /api/rss-sync/run → forcer un cycle de sync maintenant (avant /:id) */
+// Routes fixes à déclarer avant /:id
router.post('/rss-sync/run', requireAuth, async (_req, res) => {
try {
const result = await runRssSync()
res.json({ ok: true, ...result })
} catch (err) {
- logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('rss-sync', `Échec de la vérification manuelle des séries surveillées : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
const { series, complete } = await loadCatalogStatus()
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
})
-/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && s.name)
- if (series.length === 0) { res.status(400).json({ error: 'series requis' }); return }
+ if (series.length === 0) { res.status(400).json({ error: 'Aucune série sélectionnée' }); return }
const { changed, map } = setSyncBulk(series, enabled)
- logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} en masse — ${changed} série(s) modifiée(s) sur ${series.length}`)
+ logger.info('rss-sync', `Surveillance ${enabled ? 'activée' : 'désactivée'} pour plusieurs séries : ${changed} série(s) modifiée(s) sur ${series.length}`)
res.json({ ok: true, changed, total: Object.keys(map).length })
})
-/** GET /api/rss-sync/:id → statut sync pour une série */
router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
res.json({ synced: isSynced(serieId) })
})
-/** POST /api/rss-sync/:id → activer la surveillance */
router.post('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const serieName = String(req.body.serieName ?? '')
- if (!serieId || !serieName) { res.status(400).json({ error: 'serieId et serieName requis' }); return }
+ if (!serieId || !serieName) { res.status(400).json({ error: 'Série manquante' }); return }
const map = setSync(serieId, serieName, true)
- logger.info('rss-sync', `Surveillance activée pour "${serieName}" (id ${serieId})`)
+ logger.info('rss-sync', `Surveillance activée pour « ${serieName} » (série ${serieId})`)
res.json({ synced: true, total: Object.keys(map).length })
})
-/** DELETE /api/rss-sync/:id → désactiver la surveillance */
router.delete('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id)
const map = setSync(serieId, '', false)
diff --git a/server/routes/series.ts b/server/routes/series.ts
index 4d2cacf..f19e567 100644
--- a/server/routes/series.ts
+++ b/server/routes/series.ts
@@ -10,12 +10,18 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac
import { loadSynced } from '../lib/rss-sync.js'
import {
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
- extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
+ extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, countOrganizedEpisodes,
deduplicateEpisodes, serieHasEpisodes,
} from '../lib/serie-helpers.js'
+import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router()
+router.get('/library/recent', requireAuth, (req, res) => {
+ const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
+ res.json(recentImportsBySerie().slice(0, limit))
+})
+
router.get('/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
@@ -30,16 +36,23 @@ router.get('/series', requireAuth, async (_req, res) => {
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
const serieDataMap = new Map()
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
+ const lastImports = new Map(recentImportsBySerie().map(r => [r.serieId, r.at]))
res.json({ series: seriesRaw.map((serie: any) => {
const serieData = serieDataMap.get(serie.id) ?? null
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
- return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
+ const counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }
+ return {
+ ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0,
+ download_state: computeSerieDownloadState(serieData, organized, activeTorrents, counts),
+ episode_count: counts.total, organized_count: counts.organized, last_imported_at: lastImports.get(serie.id) ?? null,
+ in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id],
+ }
})})
} catch (err) {
- logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec du chargement des séries : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
@@ -66,7 +79,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
}
}
if (serieData) {
- // Lookup rapide hash → torrent pack (intégrale ou saison)
+ // Index hash : pack (intégrale ou saison)
const torrentByHash = new Map()
for (const t of (serieData.torrents ?? []))
if (t.infohash) torrentByHash.set(t.infohash.toLowerCase(), { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title ?? '', fankai: t.fankai ?? true, torrent_name: t.torrent_name ?? null })
@@ -112,9 +125,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organizedEpisodeIds.add(ep.id)
}
}
- // Fallback : si l'épisode n'a pas de torrent individuel,
- // on crée un torrent synthétique depuis ep.paths (fichier dans un pack).
- // On ajoute TOUTES les entrées valides (un pack par path unique).
if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set()
for (const pathEntry of (ep.paths ?? [])) {
@@ -161,8 +171,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organized: organizedEpisodeIds.has(ep.id),
}
})
- // Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
- // sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
@@ -187,8 +195,8 @@ router.get('/series/:id', requireAuth, async (req, res) => {
res.json({ serie, seasons: enrichedSeasons, scraper_synced: serieHasEpisodes(serieData), torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, raw: t.title ?? t.raw, torrent_name: t.torrent_name ?? null })) })
} catch (err) {
- logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec du chargement de la série ${id} : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/routes/system.ts b/server/routes/system.ts
index 39d0c72..e804660 100644
--- a/server/routes/system.ts
+++ b/server/routes/system.ts
@@ -9,13 +9,23 @@ import { systemInfo, checkPaths } from '../system.js'
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js'
-import { cacheSize } from '../lib/github-cache.js'
+import { cacheSize, cacheFetchedAt } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
-import { workerRunning } from '../organize.js'
+import { workerRunning, lastScan } from '../organize.js'
const router = Router()
-// ── Logs ───────────────────────────────────────────────────────
+const UNEXPECTED_ERROR = 'Erreur inattendue, consultez les journaux'
+
+function readDirError(dirPath: string, err: unknown): string {
+ const code = (err as NodeJS.ErrnoException)?.code
+ if (code === 'ENOENT') return `Dossier introuvable : ${dirPath}`
+ if (code === 'EACCES' || code === 'EPERM') return `Accès refusé au dossier : ${dirPath}`
+ if (code === 'ENOTDIR') return `Ce chemin n'est pas un dossier : ${dirPath}`
+ return `Dossier inaccessible : ${dirPath}`
+}
+
+// ── Journaux ───────────────────────────────────────────────────
router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all'
@@ -25,7 +35,7 @@ router.get('/logs', requireAuth, (req, res) => {
router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs()
- logger.info('api', 'Logs effacés')
+ logger.info('api', 'Journaux effacés')
res.json({ ok: true })
})
@@ -39,7 +49,7 @@ router.get('/system', requireAuth, (_req, res) => {
router.post('/system/check-paths', requireAuth, (req, res) => {
const { mediaPath, completePath } = req.body ?? {}
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) {
- res.status(400).json({ error: 'mediaPath requis' }); return
+ res.status(400).json({ error: 'Chemin de la médiathèque requis' }); return
}
res.json(checkPaths(mediaPath, completePath))
})
@@ -54,7 +64,7 @@ router.get('/version', (_req, res) => {
}
})
-// ── Parcours fichiers ──────────────────────────────────────────
+// ── Parcours des dossiers ──────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
@@ -74,7 +84,7 @@ router.get('/browse', requireAuth, (req, res) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
- // Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
+ // Sous Windows, remonter à '/' depuis la racine d'un lecteur (ex. C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
@@ -82,13 +92,13 @@ router.get('/browse', requireAuth, (req, res) => {
parent = '/'
} else {
const up = path.dirname(dirPath)
- parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
+ parent = up === dirPath ? null : up // dirname de C:\ renvoie C:\ : pas de parent
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
- logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
+ logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
+ res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -120,8 +130,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files, exists: true })
} catch (err) {
- logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
+ logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
+ res.status(400).json({ error: readDirError(dirPath, err) })
}
})
@@ -129,7 +139,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
router.get('/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
- res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
+ res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0, syncedAt: cacheFetchedAt('available.json') })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
@@ -139,19 +149,19 @@ router.post('/update', requireAuth, async (req, res) => {
try {
cacheClear()
logger.info('api', force
- ? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés'
- : 'Cache GitHub vidé — rechargement forcé')
+ ? 'Resynchronisation complète du catalogue (caches ignorés), noms de fichiers recalculés'
+ : 'Cache GitHub vidé : rechargement du catalogue')
const availableIds = await readAvailable(force)
- if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
- logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
+ if (!Array.isArray(availableIds)) throw new Error('Catalogue GitHub invalide (available.json)')
+ logger.info('api', `Catalogue rechargé : ${availableIds.length} séries disponibles`)
if (force) {
const seriesData = await loadCatalog(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
- logger.info('api', `Forçage terminé — ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
+ logger.info('api', `Resynchronisation terminée : ${updated} ID(s) migré(s), ${removed} doublon(s) retiré(s), ${renamed} fichier(s) renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return
}
@@ -162,34 +172,36 @@ router.post('/update', requireAuth, async (req, res) => {
const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
- logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
+ logger.info('api', `Renommage après synchronisation : ${renamed} fichier(s) renommé(s)`)
} catch (err) {
- logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
+ logger.warn('api', `Échec du renommage après synchronisation : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
- logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec de la synchronisation du catalogue : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
+router.get('/scan', requireAuth, (_req, res) => {
+ res.json({ lastScan })
+})
+
router.post('/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
- logger.info('api', 'Scan médiathèque manuel lancé')
+ logger.info('api', 'Analyse manuelle de la médiathèque lancée')
const seriesData = await loadCatalog()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result })
} catch (err) {
- logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
- res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+ logger.error('api', `Échec de l'analyse de la médiathèque : ${err instanceof Error ? err.message : err}`)
+ res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
}
})
-// ── Debug ──────────────────────────────────────────────────────
+// ── Diagnostic ─────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => {
- const { devMode } = readSettings()
- if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
@@ -205,7 +217,7 @@ router.get('/debug/stats', requireAuth, (req, res) => {
} catch {}
res.json({
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
- cache : { entries: cacheSize(), ttlHours: 6 },
+ cache : { entries: cacheSize(), ttlHours: 1 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker : { running: workerRunning },
diff --git a/server/routes/torrent-clients.ts b/server/routes/torrent-clients.ts
index 12ff357..9f92c4a 100644
--- a/server/routes/torrent-clients.ts
+++ b/server/routes/torrent-clients.ts
@@ -18,8 +18,8 @@ router.get('/torrent-clients', requireAuth, (_req, res) => {
router.post('/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 }
+ if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
+ if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config)))
})
@@ -31,8 +31,8 @@ router.delete('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.put('/torrent-clients/:uuid', 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 }
+ if (!name || !type || !config) { res.status(400).json({ error: 'Nom, type et réglages du client requis' }); return }
+ if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
@@ -51,9 +51,9 @@ router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body
- if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
+ if (!type || !config) { res.status(400).json({ error: 'Type et réglages du client requis' }); return }
const driver = getDriver(type)
- if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
+ if (!driver) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
@@ -71,7 +71,7 @@ router.post('/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 }
+ if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.test(client.config))
})
@@ -79,7 +79,7 @@ router.get('/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 }
+ if (!driver) { res.status(400).json({ error: 'Type de client non pris en charge' }); return }
res.json(await driver.healthcheck(client.config))
})
diff --git a/server/routes/users.ts b/server/routes/users.ts
index da399ef..48f69d7 100644
--- a/server/routes/users.ts
+++ b/server/routes/users.ts
@@ -6,60 +6,54 @@ import {
const router = Router()
-// GET /api/users
router.get('/users', (_req, res) => {
res.json(readUsers().map(safeUser))
})
-// POST /api/users
router.post('/users', (req, res) => {
const { username, password, role } = req.body
if (!username || !password) {
- res.status(400).json({ error: 'username et password requis' }); return
+ res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
if (password.length < 6) {
- res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
+ res.status(400).json({ error: 'Le mot de passe doit contenir au moins 6 caractères' }); return
}
try {
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
res.json(safeUser(user))
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-// PATCH /api/users/:id
router.patch('/users/:id', (req, res) => {
const { username, password, role } = req.body
try {
const user = updateUser(req.params.id, { username, password, role })
res.json(safeUser(user))
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-// DELETE /api/users/:id
router.delete('/users/:id', (req, res) => {
- // Empêche l'admin de se supprimer lui-même
if (req.user?.id === req.params.id) {
- res.status(400).json({ error: 'Impossible de supprimer son propre compte' }); return
+ res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }); return
}
try {
deleteUser(req.params.id)
res.json({ success: true })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
-// POST /api/users/:id/regenerate-token
router.post('/users/:id/regenerate-token', (req, res) => {
try {
const token = regenerateApiToken(req.params.id)
res.json({ apiToken: token })
} catch (err) {
- res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
+ res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
diff --git a/server/secret.ts b/server/secret.ts
index 2824bdb..3158f93 100644
--- a/server/secret.ts
+++ b/server/secret.ts
@@ -1,10 +1,3 @@
-/**
- * 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'
@@ -18,25 +11,22 @@ function generateSecret(): string {
}
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')
+ console.log(`[auth] Secret JWT généré dans ${SECRET_PATH}`)
} catch (err) {
- console.error('[auth] Impossible de sauvegarder le secret:', err)
+ console.error('[auth] Impossible d\'enregistrer le secret JWT :', err)
}
return secret
diff --git a/server/settings.ts b/server/settings.ts
index 0522051..f69f14e 100644
--- a/server/settings.ts
+++ b/server/settings.ts
@@ -13,10 +13,10 @@ export interface Settings {
autoImport : boolean
devMode : boolean
deleteTorrentOnMove : boolean
- requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé
- jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
- jellyfinAdminToken : string // Token admin Jellyfin pour la sync
- englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
+ requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
+ jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
+ jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
+ englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null
diff --git a/server/system.ts b/server/system.ts
index f92ee14..3ececf5 100644
--- a/server/system.ts
+++ b/server/system.ts
@@ -49,7 +49,7 @@ export function checkDirectory(input: string, { allowRoot = true } = {}): PathCh
const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
if (!raw) return { ...result, error: 'Chemin vide' }
- if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez une barre après la lettre du lecteur' }
+ if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez « \\ » après la lettre du lecteur (ex. C:\\)' }
if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
result.resolved = path.resolve(raw)
@@ -72,7 +72,9 @@ export function checkDirectory(input: string, { allowRoot = true } = {}): PathCh
result.writable = true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
- result.error = `Écriture impossible${code ? ` (${code})` : ''}`
+ result.error = code === 'EACCES' || code === 'EPERM'
+ ? `Écriture impossible : permissions insuffisantes (${code})`
+ : `Écriture impossible${code ? ` (${code})` : ''}`
} finally {
removeQuietly(probe)
}
@@ -134,7 +136,7 @@ export function checkPaths(mediaPath: string, completePath?: string) {
let hardlink: HardlinkCheck
if (!hasComplete) {
- hardlink = { tested: false, ok: false, message: 'Renseignez le dossier de téléchargements pour tester le hardlink' }
+ hardlink = { tested: false, ok: false, message: 'Renseignez le dossier des téléchargements pour tester le hardlink' }
} else if (!valid(media) || !valid(complete)) {
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
} else if (relation === 'same') {
diff --git a/server/torrent-clients/deluge.ts b/server/torrent-clients/deluge.ts
index c79a200..9eee4cb 100644
--- a/server/torrent-clients/deluge.ts
+++ b/server/torrent-clients/deluge.ts
@@ -1,20 +1,10 @@
-/**
- * Deluge Web JSON-RPC driver
- *
- * Auth : POST {url}/json → auth.login(password) → cookie _session_id
- * RPC : POST {url}/json avec { method, params, id } + Cookie header
- * States : "Downloading" | "Seeding" | "Paused" | "Checking"
- * "Error" | "Queued" | "Allocating" | "Moving"
- * Progress : float 0–100 (déjà en pourcentage, pas 0–1)
- */
+/** Deluge via l'API JSON-RPC de l'interface web. Progression en pourcentage (0–100). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
-// ─── Session cache ─────────────────────────────────────────────────────────────
-// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
-// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
+// ─── Sessions ──────────────────────────────────────────────────────────────────
interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map()
@@ -23,14 +13,14 @@ function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}`
}
-// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
+// ─── HTTP ──────────────────────────────────────────────────────────────────────
function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
}
function parseCookie(raw: string): string {
- // "set-cookie: _session_id=abc123; Path=/; HttpOnly" → "_session_id=abc123"
+ // "_session_id=abc123; Path=/; HttpOnly" devient "_session_id=abc123"
return raw.split(';')[0].trim()
}
@@ -51,14 +41,11 @@ async function delugeRPC(
body: JSON.stringify({ method, params, id: 1 }),
})
- if (!res.ok) {
- const text = await res.text().catch(() => '')
- throw new Error(`Deluge HTTP ${res.status} : ${text}`)
- }
+ if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
if (json.error) {
- throw new Error(`Deluge RPC : ${json.error.message ?? JSON.stringify(json.error)}`)
+ throw new Error(`Erreur Deluge : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
}
const setCookie = res.headers.get('set-cookie')
@@ -68,7 +55,7 @@ async function delugeRPC(
}
}
-// ─── Login ─────────────────────────────────────────────────────────────────────
+// ─── Connexion ─────────────────────────────────────────────────────────────────
async function delugeLogin(config: ClientConfig): Promise {
const url = delugeUrl(config)
@@ -78,22 +65,19 @@ async function delugeLogin(config: ClientConfig): Promise {
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
})
- if (!res.ok) {
- const text = await res.text().catch(() => '')
- throw new Error(`Deluge HTTP ${res.status} : ${text}`)
- }
+ if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const json = await res.json()
- if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`)
- if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)')
+ if (json.error) throw new Error(`Connexion à Deluge refusée : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
+ if (!json.result) throw new Error('Connexion à Deluge refusée : mot de passe incorrect ?')
const setCookie = res.headers.get('set-cookie')
- if (!setCookie) throw new Error('Deluge : pas de cookie de session reçu après login')
+ if (!setCookie) throw new Error('Échec de la connexion à Deluge : aucun cookie de session reçu')
return parseCookie(setCookie)
}
-// ─── Call avec gestion de session ─────────────────────────────────────────────
+// ─── Appel avec gestion de session ────────────────────────────────────────────
async function delugeCall(
config : ClientConfig,
@@ -103,7 +87,6 @@ async function delugeCall(
const url = delugeUrl(config)
const key = sessionKey(config)
- // Récupère ou rafraîchit la session
let entry = _sessions.get(key)
if (!entry || Date.now() >= entry.expires) {
const cookie = await delugeLogin(config)
@@ -113,7 +96,6 @@ async function delugeCall(
try {
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
- // Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) {
entry.cookie = newCookie
entry.expires = Date.now() + 30 * 60_000
@@ -121,7 +103,6 @@ async function delugeCall(
return result
} catch (err) {
const msg = err instanceof Error ? err.message : ''
- // Session expirée → relogin unique
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
_sessions.delete(key)
const cookie = await delugeLogin(config)
@@ -134,7 +115,7 @@ async function delugeCall(
}
}
-// ─── Mapping état ──────────────────────────────────────────────────────────────
+// ─── États ─────────────────────────────────────────────────────────────────────
function mapState(state: string): TorrentInfo['state'] {
switch (state) {
@@ -175,9 +156,7 @@ function flattenDelugeFiles(
return result
}
-// ─── Sélection de fichier (asynchrone, best-effort) ──────────────────────────
-// Attend que les métadonnées soient disponibles (~60 s max) puis applique
-// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
+// ─── Sélection de fichier (en arrière-plan) ──────────────────────────────────
async function applyFilePriority(
config : ClientConfig,
@@ -193,14 +172,14 @@ async function applyFilePriority(
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
- logger.info('deluge', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
+ logger.info('deluge', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
- logger.warn('deluge', `Timeout : priorité fichier non appliquée pour ${hash.slice(0, 8)}…`)
+ logger.warn('deluge', `Délai dépassé : priorité de fichier non appliquée pour ${hash.slice(0, 8)}…`)
}
-// ─── Driver ───────────────────────────────────────────────────────────────────
+// ─── Client ───────────────────────────────────────────────────────────────────
const delugeDriver: TorrentClientDriver = {
definition: {
@@ -209,15 +188,14 @@ const delugeDriver: TorrentClientDriver = {
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
- { key: 'category', label: 'Catégorie (Label plugin)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
- { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
- { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
- { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
+ { key: 'category', label: 'Catégorie (plugin Label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
+ { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
+ { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
+ { key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
- // ── Test ───────────────────────────────────────────────────────────────────
async test(config) {
try {
_sessions.delete(sessionKey(config))
@@ -226,16 +204,14 @@ const delugeDriver: TorrentClientDriver = {
logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('deluge', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
- // ── Healthcheck ────────────────────────────────────────────────────────────
async healthcheck(config) {
try {
- // daemon.get_version() retourne la version du daemon Deluge
const version = await delugeCall(config, 'daemon.get_version')
return { online: true, version: String(version ?? 'inconnue') }
} catch {
@@ -243,15 +219,13 @@ const delugeDriver: TorrentClientDriver = {
}
},
- // ── List ───────────────────────────────────────────────────────────────────
async list(config, category?) {
const keys = [
'name', 'state', 'progress', 'total_size', 'total_done',
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
'eta', 'save_path', 'label',
]
- // filter_dict vide → tous les torrents ; le filtrage par label se fait en JS
- // pour ne pas dépendre du Label plugin côté serveur.
+ // Tous les torrents, filtrés par label ici pour ne pas dépendre du plugin Label
const raw: Record = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
return Object.entries(raw)
@@ -273,7 +247,6 @@ const delugeDriver: TorrentClientDriver = {
}))
},
- // ── Add ────────────────────────────────────────────────────────────────────
async add(config, url, options?: DownloadOptions) {
const addOpts: Record = {}
if (config.savePath) addOpts['download_location'] = String(config.savePath)
@@ -282,36 +255,32 @@ const delugeDriver: TorrentClientDriver = {
if (url.startsWith('magnet:')) {
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
- logger.info('deluge', `Magnet ajouté → ${hash?.slice(0, 8) ?? '?'}…`)
+ logger.info('deluge', `Magnet ajouté (hash ${hash?.slice(0, 8) ?? '?'}…)`)
} else {
- // Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
const torrentRes = await fetch(url)
- if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent : ${torrentRes.status}`)
+ if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const buf = await torrentRes.arrayBuffer()
const b64 = Buffer.from(buf).toString('base64')
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
- logger.info('deluge', `.torrent uploadé → ${hash?.slice(0, 8) ?? '?'}…`)
+ logger.info('deluge', `Fichier .torrent envoyé (hash ${hash?.slice(0, 8) ?? '?'}…)`)
}
- // Label plugin — best-effort (silencieux si plugin absent)
+ // Plugin Label facultatif : erreurs ignorées
if (config.category && hash) {
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
}
- // Sélection de fichier — asynchrone, best-effort
if (options?.file_index != null && hash) {
applyFilePriority(config, hash, options.file_index).catch(() => {})
}
},
- // ── Remove ─────────────────────────────────────────────────────────────────
async remove(config, hash, deleteFiles = false) {
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
- // ── Get files ──────────────────────────────────────────────────────────────
async getFiles(config, hash) {
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
if (!data?.contents) return []
diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts
index 83dac7c..4a3a48e 100644
--- a/server/torrent-clients/index.ts
+++ b/server/torrent-clients/index.ts
@@ -1,8 +1,3 @@
-/**
- * Torrent Client Registry
- * =======================
- * Interface commune + registry de tous les clients disponibles.
- */
import { randomUUID } from 'crypto'
import fs from 'fs'
@@ -29,8 +24,7 @@ export interface TorrentClientDefinition {
id : string
label : string
fields : FieldDef[]
- /** Si true, dispatchList filtre les résultats pour n'inclure que les
- * torrents ajoutés via FanKarr (clients sans catégories natives). */
+ /** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
filterByManagedHashes?: boolean
}
@@ -43,9 +37,9 @@ export interface SavedClient {
export interface TorrentFileProgress {
index : number
- name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
+ name ?: string // chemin relatif dans le torrent (ex. "Pack S1/Episode1.mkv"), selon le client
progress: number // 0–1
- priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
+ priority: number // 0 = non téléchargé, 1 = normale, 6 = haute, 7 = maximale
}
export interface TorrentInfo {
@@ -68,8 +62,8 @@ export interface TorrentInfo {
export interface DownloadOptions {
file_index?: number | null
file_path ?: string | null
- infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet)
- magnet ?: string | null // fallback pour extraire le hash si infohash absent
+ infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
+ magnet ?: string | null
}
export interface TorrentClientDriver {
@@ -83,9 +77,7 @@ export interface TorrentClientDriver {
}
// ─── Certificats TLS ──────────────────────────────────────────────────────────
-// Option "ignoreCertificateErrors" (par client) : accepte les certificats
-// auto-signés/invalides. Node → Agent undici (cf. plex.ts) ; Bun → option
-// `tls` de son fetch natif (l'Agent undici n'y est pas honoré).
+// ignoreCertificateErrors : Agent undici sous Node, option tls du fetch sous Bun
let _insecureAgent: Agent | null = null
@@ -99,14 +91,13 @@ export function ignoreCertificateErrors(config: ClientConfig): boolean {
return v === true || v === 'true' || v === 1
}
-// Option `tls` propre au fetch de Bun, absente des types standards.
+// Option tls propre à Bun, absente des types standards
interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean }
}
const isBun = 'bun' in process.versions
-/** fetch du client : accepte les certificats invalides si l'option est active. */
export function clientFetch(
config : ClientConfig,
url : string,
@@ -120,13 +111,13 @@ export function clientFetch(
return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters[1]) as unknown as Promise
}
-// ─── Registry ─────────────────────────────────────────────────────────────────
+// ─── Registre ─────────────────────────────────────────────────────────────────
const drivers = new Map()
export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver)
- logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
+ logger.debug('torrent-clients', `Type de client enregistré : ${driver.definition.label}`)
}
export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -137,15 +128,12 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition)
}
-// ─── Persistence ──────────────────────────────────────────────────────────────
+// ─── Stockage ─────────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
-// ─── Managed tasks ─────────────────────────────────────────────────────────────
-// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
-// Utilisé par dispatchList pour filtrer les résultats des clients sans
-// catégories natives (ex: Synology Download Station).
-// Structure : { clientUuid: [infohash, ...] }
+// ─── Torrents suivis ──────────────────────────────────────────────────────────
+// Hashes ajoutés par FanKarr, par client : filtre les clients sans catégories (Synology)
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
@@ -163,14 +151,12 @@ function saveManagedMap(data: Record) {
} catch {}
}
-/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
function getManagedSet(clientUuid: string): Set | null {
const list = loadManagedMap()[clientUuid]
if (!list || list.length === 0) return null
return new Set(list)
}
-/** Enregistre un infohash comme géré pour un client (idempotent). */
export function trackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) data[clientUuid] = []
@@ -181,7 +167,6 @@ export function trackManagedHash(clientUuid: string, infohash: string) {
}
}
-/** Retire un infohash de la liste gérée pour un client. */
export function untrackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
@@ -190,7 +175,6 @@ export function untrackManagedHash(clientUuid: string, infohash: string) {
saveManagedMap(data)
}
-/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
function clearManagedHashes(clientUuid: string) {
const data = loadManagedMap()
if (!data[clientUuid]) return
@@ -221,7 +205,7 @@ export function addClient(name: string, type: string, config: ClientConfig): Sav
const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client)
saveClients(clients)
- logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
+ logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
return client
}
@@ -232,7 +216,7 @@ export function removeClient(uuid: string): boolean {
if (filtered.length === clients.length) return false
saveClients(filtered)
clearManagedHashes(uuid)
- logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
+ logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
return true
}
@@ -246,7 +230,7 @@ export function updateClient(uuid: string, name: string, type: string, config: C
if (index === -1) return null
clients[index] = { ...clients[index], name, type, config }
saveClients(clients)
- logger.info('torrent-clients', `Client modifié : "${name}" (${type})`)
+ logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
return clients[index]
}
@@ -262,8 +246,8 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
-// ─── Backoff par client ───────────────────────────────────────────────────────
-// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
+// ─── Pause après échecs de connexion ──────────────────────────────────────────
+// Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
interface BackoffEntry {
failCount : number
@@ -272,7 +256,6 @@ interface BackoffEntry {
const _backoff = new Map()
-// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean {
@@ -286,7 +269,7 @@ function cooldownRemaining(uuid: string): string {
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
- return m > 0 ? `${m}min` : ''
+ return m > 0 ? `${m} min` : ''
}
function registerFailure(uuid: string, clientName: string) {
@@ -296,14 +279,14 @@ function registerFailure(uuid: string, clientName: string) {
entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry)
const min = Math.round(delay / 60_000)
- logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
+ logger.warn('torrent-clients', `Client « ${clientName} » en pause ${min} min après ${entry.failCount} échec(s) de connexion`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
-// Exposé pour l'API de status
+// Exposé pour l'API d'état
export function getClientCooldowns(): Record {
const result: Record = {}
for (const [uuid, entry] of _backoff.entries()) {
@@ -312,11 +295,9 @@ export function getClientCooldowns(): Record {
const clients = loadClients()
if (clients.length === 0) {
- logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
+ logger.warn('torrent-clients', 'Aucun client torrent configuré : téléchargement impossible')
return []
}
@@ -340,21 +321,21 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) {
- logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
- results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
+ logger.error('torrent-clients', `Type de client « ${client.type} » non pris en charge (client « ${client.name} »)`)
+ results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Type de client « ${client.type} » non pris en charge` })
continue
}
try {
await driver.add(client.config, url, options)
if (options?.infohash) {
trackManagedHash(client.uuid, options.infohash)
- logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
+ logger.debug('torrent-clients', `Torrent ${options.infohash.slice(0, 8)}… suivi pour « ${client.name} »`)
}
- logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
+ logger.info('torrent-clients', `Téléchargement envoyé à « ${client.name} »`)
results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.error('torrent-clients', `Échec de l'envoi à « ${client.name} » : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
}
}
@@ -362,7 +343,7 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
return results
}
-// ─── Dispatch remove ───────────────────────────────────────────────────────────
+// ─── Suppression ──────────────────────────────────────────────────────────────
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients()
@@ -372,18 +353,18 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
if (!driver) continue
try {
await driver.remove(client.config, hash, deleteFiles)
- logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de "${client.name}"${deleteFiles ? ' (avec fichiers)' : ''}`)
+ logger.info('torrent-clients', `Torrent ${hash.slice(0, 8)}… supprimé de « ${client.name} »${deleteFiles ? ' (avec fichiers)' : ''}`)
untrackManagedHash(client.uuid, hash)
found = true
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('torrent-clients', `Impossible de supprimer le torrent de "${client.name}" : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('torrent-clients', `Impossible de supprimer le torrent de « ${client.name} » : ${msg}`)
}
}
- return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients' }
+ return found ? { ok: true } : { ok: false, error: 'Torrent introuvable dans les clients torrent' }
}
-// Map inversée : titre normalisé → infohash (pour matcher les torrents Synology)
+// Titre normalisé vers infohash, pour retrouver les torrents Synology
function buildTitleIndex(infohashMap: Record): Map {
const index = new Map()
for (const [hash, title] of Object.entries(infohashMap)) {
@@ -406,7 +387,7 @@ export async function dispatchList(
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
- logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
+ logger.debug('torrent-clients', `Client « ${client.name} » en pause, nouvel essai dans ${remaining}`)
continue
}
@@ -425,20 +406,16 @@ export async function dispatchList(
? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path
- // Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash
- // en matchant le titre du torrent dans la infohashMap
+ // Synology (dbid_xxx) : retrouver l'infohash par le titre
let hash = t.hash
if (titleIndex && t.hash.startsWith('dbid_')) {
const resolved = titleIndex.get(t.name.toLowerCase().trim())
if (resolved) {
- logger.debug('torrent-clients', `Synology hash résolu : "${t.name}" → ${resolved.slice(0, 8)}…`)
+ logger.debug('torrent-clients', `Hash Synology résolu : « ${t.name} » (${resolved.slice(0, 8)}…)`)
hash = resolved
}
}
- // Filtre par hashes gérés pour les clients sans catégories natives.
- // Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
- // on ignore ce torrent (il n'a pas été ajouté via FanKarr).
if (driver.definition.filterByManagedHashes) {
const managed = getManagedSet(client.uuid)
if (managed && !managed.has(hash)) continue
@@ -447,14 +424,11 @@ export async function dispatchList(
results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
- const isAuthError = msg.toLowerCase().includes('login') ||
- msg.toLowerCase().includes('auth') ||
- msg.toLowerCase().includes('ban') ||
- msg.toLowerCase().includes('forbidden') ||
- msg.toLowerCase().includes('401') ||
- msg.toLowerCase().includes('403')
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.error('torrent-clients', `Impossible de récupérer la liste des torrents de « ${client.name} » : ${msg}`)
+ const lower = msg.toLowerCase()
+ const isAuthError = ['login', 'auth', 'ban', 'forbidden', '401', '403', 'connexion refusée', 'refusé la connexion',
+ 'connexion à deluge refusée', 'clé api', 'échec de la connexion'].some(k => lower.includes(k))
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
@@ -462,9 +436,7 @@ export async function dispatchList(
return results
}
-// ─── Dispatch file progress ────────────────────────────────────────────────────
-// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
-// clientUuid optionnel : si fourni, interroge uniquement ce client.
+// ─── Progression par fichier ──────────────────────────────────────────────────
export async function dispatchGetFiles(
hash : string,
diff --git a/server/torrent-clients/qbittorrent.ts b/server/torrent-clients/qbittorrent.ts
index fb9ccd0..eb63f5d 100644
--- a/server/torrent-clients/qbittorrent.ts
+++ b/server/torrent-clients/qbittorrent.ts
@@ -1,14 +1,10 @@
-/**
- * qBittorrent Driver
- * Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
- */
+/** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
-// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.)
-// peuvent wrapper la réponse en JSON — on accepte les deux
+// Réponse « Ok. », ou JSON derrière certains reverse proxies
function isQbAddSuccess(text: string): boolean {
if (text.trim() === 'Ok.') return true
try {
@@ -27,7 +23,6 @@ function mapState(state: string): TorrentInfo['state'] {
return 'unknown'
}
-// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: ClientConfig): Promise> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
@@ -48,7 +43,7 @@ async function qbLogin(config: ClientConfig): Promise {
})
const text = await res.text()
- if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
+ if (!res.ok) throw new Error(`Échec de la connexion : ${text.trim() || `HTTP ${res.status}`}`)
if (text.trim() === 'Fails.') throw new Error('Identifiants incorrects (vérifiez le nom d\'utilisateur et le mot de passe)')
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
@@ -69,16 +64,11 @@ async function qbTorrentExists(config: ClientConfig, authHeaders: Record {
- // Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
@@ -91,13 +81,10 @@ async function qbApplyFilePriority(
if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) {
- throw new Error(`Index fichier ${fileIndex} hors limites (torrent a ${files.length} fichiers)`)
+ throw new Error(`Fichier n° ${fileIndex} hors limites : le torrent contient ${files.length} fichiers`)
}
- // Si tous les fichiers sont à priorité normale (état initial du torrent),
- // on applique le filtrage complet : on désactive tout sauf la cible.
- // Si certains sont déjà à 0 (filtrage déjà effectué pour un épisode précédent),
- // on se contente d'activer la nouvelle cible sans toucher aux autres.
+ // Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
@@ -107,37 +94,35 @@ async function qbApplyFilePriority(
if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '0' })
const r = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, { method: 'POST', body, headers })
- if (!r.ok) { logger.warn('qbittorrent', `filePrio désactivation échouée (${r.status}) — retry`); continue }
+ if (!r.ok) { logger.warn('qbittorrent', `Priorités non appliquées (HTTP ${r.status}), nouvel essai`); continue }
}
}
- // Activer le fichier cible (dans tous les cas)
const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers,
})
- if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
+ if (!r2.ok) { logger.warn('qbittorrent', `Priorité du fichier cible non appliquée (HTTP ${r2.status}), nouvel essai`); continue }
- // Vérification : le fichier cible est bien à priorité > 0
const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) {
- logger.warn('qbittorrent', `Fichier ${fileIndex} toujours à priorité 0 après application — retry`)
+ logger.warn('qbittorrent', `Fichier n° ${fileIndex} toujours à priorité 0 après application, nouvel essai`)
continue
}
}
- logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
+ logger.info('qbittorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw err
}
}
- throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
+ throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const QB: TorrentClientDriver = {
@@ -145,14 +130,14 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent',
label : 'qBittorrent',
fields: [
- { key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
- { key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
- { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
- { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
- { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
- { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
- { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
- { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
+ { key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
+ { key: 'apiKey', label: 'Clé API (qBittorrent 5.2 ou plus)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
+ { key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
+ { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
+ { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
+ { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
+ { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
+ { key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -165,8 +150,8 @@ const QB: TorrentClientDriver = {
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('qbittorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -179,11 +164,11 @@ const QB: TorrentClientDriver = {
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
- if (version.startsWith('<')) throw new Error('Réponse HTML reçue — clé API invalide ou non supportée par cette version de qBittorrent')
- logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
+ if (version.startsWith('<')) throw new Error('Réponse HTML reçue : clé API invalide ou non prise en charge par cette version de qBittorrent')
+ logger.debug('qbittorrent', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
- logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
+ logger.debug('qbittorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -195,10 +180,9 @@ const QB: TorrentClientDriver = {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH,
})
- if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
+ if (!res.ok) throw new Error(`Liste des torrents inaccessible (HTTP ${res.status})`)
const data: any[] = await res.json()
- // Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map()
await Promise.all(data.map(async (t: any) => {
try {
@@ -255,7 +239,6 @@ const QB: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) {
const authH = await qbAuth(config)
- // Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
@@ -265,15 +248,15 @@ const QB: TorrentClientDriver = {
if (hash) {
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
- logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
+ logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
- logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
- // Snapshot des hashes avant ajout (pour retrouver le nouveau si hash inconnu)
+ // Hashes présents avant l'ajout, pour repérer le nouveau torrent si le hash est inconnu
let knownHashes: Set = new Set()
if (!hash) {
try {
@@ -282,10 +265,7 @@ const QB: TorrentClientDriver = {
} catch {}
}
- // Stratégie d'ajout selon le type de lien :
- // magnet link → URLSearchParams (application/x-www-form-urlencoded), pas de binaire
- // .torrent URL → FormData multipart obligatoire pour uploader le blob binaire
- // (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
+ // .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
const isMagnet = url.startsWith('magnet:')
let res: Response
@@ -299,10 +279,9 @@ const QB: TorrentClientDriver = {
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
} else {
- // FanKarr télécharge le .torrent et l'envoie directement à qBit
- logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
+ logger.debug('qbittorrent', `Téléchargement du fichier .torrent par FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
- if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
+ if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
const form = new FormData()
if (config.category) form.append('category', String(config.category))
@@ -311,16 +290,15 @@ const QB: TorrentClientDriver = {
res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
}
const text = await res.text()
- if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
+ if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
if (hash) {
- logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
+ logger.info('qbittorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
- logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('qbittorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
- // Hash inconnu (pas d'infohash, pas de magnet) → retrouve le nouveau torrent par diff de liste
- logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
+ logger.info('qbittorrent', `Torrent ajouté sans hash connu : recherche dans la liste (fichier n° ${options.file_index})`)
const fileIndex = options.file_index
;(async () => {
for (let attempt = 0; attempt < 20; attempt++) {
@@ -332,18 +310,17 @@ const QB: TorrentClientDriver = {
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase()
- logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
+ logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}…, sélection du fichier n° ${fileIndex}`)
await qbApplyFilePriority(config, resolvedHash, fileIndex)
return
} catch {}
}
- logger.warn('qbittorrent', `Impossible de résoudre le hash pour appliquer la priorité fichier ${fileIndex}`)
+ logger.warn('qbittorrent', `Hash introuvable : impossible de sélectionner le fichier n° ${fileIndex}`)
})().catch(() => {})
}
return
}
- // Pas de sélection de fichier → ajout normal (URLSearchParams, pas de binaire)
const params = new URLSearchParams()
params.set('urls', url)
if (config.category) params.set('category', String(config.category))
@@ -354,8 +331,8 @@ const QB: TorrentClientDriver = {
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
const text = await res.text()
- if (!isQbAddSuccess(text)) throw new Error(`Ajout échoué : ${text}`)
- logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
+ if (!isQbAddSuccess(text)) throw new Error(`qBittorrent a refusé le torrent : ${text}`)
+ logger.info('qbittorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
@@ -366,7 +343,7 @@ const QB: TorrentClientDriver = {
body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
})
- if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
+ if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
},
}
diff --git a/server/torrent-clients/real-debrid.ts b/server/torrent-clients/real-debrid.ts
index 458b097..210ad6b 100644
--- a/server/torrent-clients/real-debrid.ts
+++ b/server/torrent-clients/real-debrid.ts
@@ -1,17 +1,4 @@
-/**
- * Real-Debrid Driver
- *
- * Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
- * puis les fichiers sont accessibles via des liens directs (HTTPS).
- *
- * Workflow FanKarr :
- * 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
- * 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
- * 3. remove() → DELETE /torrents/delete/{id}
- *
- * Le save_path de chaque torrent est construit à partir du localPath configuré
- * (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
- */
+/** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js'
@@ -37,9 +24,10 @@ async function rdFetch(
body : body,
})
if (res.status === 204) return null
+ if (res.status === 401 || res.status === 403) throw new Error('Clé API Real-Debrid invalide ou compte non premium')
if (!res.ok) {
- const text = await res.text().catch(() => '')
- throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
+ const data = await res.json().catch(() => null)
+ throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
}
return res.json()
}
@@ -67,18 +55,18 @@ const rdDriver: TorrentClientDriver = {
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
- { key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
+ { key: 'localPath', label: 'Dossier où Real-Debrid est monté (rclone, WebDAV…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
- logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
+ logger.info('real-debrid', `Connexion réussie (utilisateur ${user.username})`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -86,7 +74,7 @@ const rdDriver: TorrentClientDriver = {
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
- return { online: true, version: `RD — ${user.username}` }
+ return { online: true, version: `Compte ${user.username}` }
} catch {
return { online: false }
}
@@ -98,13 +86,12 @@ const rdDriver: TorrentClientDriver = {
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
- // Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
- logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
+ logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur Real-Debrid`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
@@ -125,11 +112,10 @@ const rdDriver: TorrentClientDriver = {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
- logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
+ logger.info('real-debrid', `Magnet ajouté (ID ${torrentId})`)
} else {
- // URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
- if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
+ if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent (HTTP ${torrentRes.status})`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
@@ -138,15 +124,13 @@ const rdDriver: TorrentClientDriver = {
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
- if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
+ if (!uploadRes.ok) throw new Error(`Échec de l'envoi du fichier .torrent (HTTP ${uploadRes.status})`)
const data = await uploadRes.json()
torrentId = data.id
- logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
+ logger.info('real-debrid', `Fichier .torrent envoyé (ID ${torrentId})`)
}
- // Sélection des fichiers
if (options?.file_index != null) {
- // Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
@@ -164,16 +148,13 @@ const rdDriver: TorrentClientDriver = {
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
- logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
+ logger.info('real-debrid', `Fichier n° ${options.file_index} sélectionné pour le torrent Real-Debrid ${torrentId}`)
}
} else if (files.length > 0) {
- // Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
- // Pas de sélection spécifique : tous les fichiers
- // Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
@@ -195,10 +176,9 @@ const rdDriver: TorrentClientDriver = {
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
- // Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
+ // save_path virtuel : dossier monté et nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
- // Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
@@ -228,7 +208,6 @@ const rdDriver: TorrentClientDriver = {
},
async remove(config, hash, _deleteFiles?) {
- // Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
diff --git a/server/torrent-clients/rtorrent.ts b/server/torrent-clients/rtorrent.ts
index 5a98137..f725135 100644
--- a/server/torrent-clients/rtorrent.ts
+++ b/server/torrent-clients/rtorrent.ts
@@ -1,15 +1,10 @@
-/**
- * rTorrent Driver
- * ===============
- * Communique via XML-RPC (HTTP POST).
- * Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
- */
+/** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
-// ─── XML-RPC helpers ──────────────────────────────────────────
+// ─── XML-RPC ──────────────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `${val}`
@@ -71,21 +66,20 @@ async function rpcCall(
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await clientFetch(config, url, { method: 'POST', headers, body })
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`rTorrent a répondu HTTP ${res.status} : vérifiez l'URL et le chemin RPC`)
const text = await res.text()
- // Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
+ // Pas de DOMParser sous Node : analyse XML minimale
const faultMatch = text.match(/faultString<\/name>\s*([^<]*)<\/string>/)
- if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
+ if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`)
const valueMatch = text.match(/\s*\s*\s*([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
- // Parse simplifié pour les types courants
+ // Types courants uniquement
const inner = valueMatch[1].trim()
- // Array
if (inner.startsWith('')) {
const items = [...inner.matchAll(/([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
@@ -108,7 +102,6 @@ function parseInnerValue(inner: string): any {
return inner
}
-// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : ClientConfig,
view : string,
@@ -128,28 +121,20 @@ async function rtTorrentExists(config: ClientConfig, hash: string): Promise {
- // 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300))
try {
- // Récupérer taille ET priorité courante de chaque fichier
const files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue
- // Si tous les fichiers sont à priorité normale (état initial),
- // on filtre tout sauf la cible. Sinon on active juste la cible.
+ // Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) {
@@ -158,20 +143,17 @@ async function rtApplyFilePriority(
} else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0])
}
- // Si pas initial state : on ne touche pas aux autres fichiers
}
- logger.info('rtorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
+ logger.info('rtorrent', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
- throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
+ throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
-// ─── State mapping ────────────────────────────────────────────
-// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
-// + is_open (0/1)
+// ─── États ────────────────────────────────────────────────────
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
@@ -187,12 +169,12 @@ const RT: TorrentClientDriver = {
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
- { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
+ { key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
- { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
- { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
- { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
+ { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
+ { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
+ { key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -200,11 +182,11 @@ const RT: TorrentClientDriver = {
async test(config) {
try {
const version = await rpcCall(config, 'system.client_version')
- logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
+ logger.info('rtorrent', `Test de connexion réussi sur ${config.url} (version ${version})`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -212,10 +194,10 @@ const RT: TorrentClientDriver = {
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
- logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
+ logger.debug('rtorrent', `Client en ligne (version ${version})`)
return { online: true, version: String(version) }
} catch (err) {
- logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
+ logger.debug('rtorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -230,8 +212,8 @@ const RT: TorrentClientDriver = {
'd.complete',
'd.size_bytes',
'd.bytes_done',
- 'd.up.total', // total uploadé
- 'd.ratio', // ratio x1000
+ 'd.up.total',
+ 'd.ratio', // ratio × 1000
'd.down.rate',
'd.up.rate',
'd.left_bytes',
@@ -241,7 +223,6 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods)
- // Récupérer la progression par fichier pour chaque torrent (requêtes parallèles)
const fileMap = new Map()
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
@@ -301,42 +282,37 @@ const RT: TorrentClientDriver = {
const exists = await rtTorrentExists(config, hash)
if (exists) {
- // Torrent déjà présent → mettre à jour la priorité directement (async OK)
- logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
+ logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
- logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
- // Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif)
- // Pendant le fetch des métadonnées, aucun byte de fichier n'est téléchargé.
- // On poll à 300 ms et on set les priorités dès que les fichiers sont disponibles.
+ // Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
- logger.info('rtorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
+ logger.info('rtorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
rtApplyFilePriority(config, hash, options.file_index).catch(err =>
- logger.warn('rtorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('rtorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
- // Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args)
- logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
+ logger.info('rtorrent', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const h = hash.toUpperCase()
if (deleteFiles) {
- // Supprimer les fichiers puis effacer
await rpcCall(config, 'd.delete_tied', [h])
await rpcCall(config, 'd.erase', [h])
} else {
diff --git a/server/torrent-clients/synology-ds.ts b/server/torrent-clients/synology-ds.ts
index 3a3f785..3c811da 100644
--- a/server/torrent-clients/synology-ds.ts
+++ b/server/torrent-clients/synology-ds.ts
@@ -1,6 +1,3 @@
-/**
- * Synology Download Station Driver
- */
import fs from 'fs'
import path from 'path'
@@ -9,18 +6,13 @@ import { clientFetch } from './index.js'
import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js'
-// ─── Cache URI → infohash ──────────────────────────────────────────────────────
-// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
-// On persiste l'association URI → infohash au moment du add() pour la réutiliser
-// dans list() via le champ additional.detail.uri de chaque tâche.
+// ─── Cache URI et infohash ─────────────────────────────────────────────────────
+// DSM anciens : pas de hash BT dans l'API, on mémorise l'URI et son infohash à l'ajout
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
-// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
-// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
-// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
-// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
-// même quand detail.hash et detail.uri sont vides.
+// ─── Cache hash BT et ID Synology (en mémoire) ────────────────────────────────
+// Hash BT vers dbid_X, pour que remove() et getFiles() retrouvent la tâche
const _hashToSynoId = new Map()
function loadUriHashMap(): Record {
@@ -38,7 +30,7 @@ const _uriToHash: Map = (() => {
function storeUriHash(uri: string, hash: string) {
const key = uri.toLowerCase()
- if (_uriToHash.get(key) === hash) return // déjà connu, pas besoin d'écrire
+ if (_uriToHash.get(key) === hash) return
_uriToHash.set(key, hash)
try {
const map = loadUriHashMap()
@@ -52,24 +44,24 @@ function resolveHash(detailHash: string, detailUri: string, taskId: string): str
// 1. Hash direct depuis l'API Synology (DSM récent)
if (detailHash.length > 0) return detailHash.toLowerCase()
- // 2. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
+ // 2. Hash extrait du magnet de detail.uri
if (detailUri.startsWith('magnet:')) {
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
if (m) return m[1].toLowerCase()
}
- // 3. Notre cache URI → infohash (stocké au moment du add())
+ // 3. Cache URI mémorisé à l'ajout
if (detailUri) {
const cached = _uriToHash.get(detailUri.toLowerCase())
if (cached) return cached
}
- // 4. Fallback : ID Synology natif (déjà au format "dbid_X")
- logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
+ // 4. À défaut : ID Synology natif (dbid_X)
+ logger.debug('synology-ds', `Hash BT non résolu, ID Synology utilisé : ${taskId}`)
return String(taskId ?? '').toLowerCase()
}
-// ─── State mapping ─────────────────────────────────────────────────────────────
+// ─── États ─────────────────────────────────────────────────────────────────────
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
@@ -82,7 +74,31 @@ function mapState(status: string): TorrentInfo['state'] {
return 'unknown'
}
-// ─── Auth ──────────────────────────────────────────────────────────────────────
+// ─── Authentification ──────────────────────────────────────────────────────────
+
+const AUTH_ERRORS: Record = {
+ 400: 'nom d\'utilisateur ou mot de passe incorrect',
+ 401: 'compte désactivé',
+ 402: 'accès refusé',
+ 403: 'code de validation en deux étapes requis',
+ 404: 'code de validation en deux étapes refusé',
+}
+
+const TASK_ERRORS: Record = {
+ 105: 'accès refusé',
+ 106: 'session expirée',
+ 400: 'envoi du fichier refusé',
+ 401: 'nombre maximal de tâches atteint',
+ 402: 'accès au dossier de destination refusé',
+ 403: 'dossier de destination introuvable',
+ 406: 'aucun dossier de destination par défaut',
+ 408: 'fichier introuvable',
+}
+
+function dsErrorText(error: any, table: Record): string {
+ const code = error?.code
+ return table[code] ? `${table[code]} (code ${code})` : `code ${code ?? 'inconnu'}`
+}
async function dsLogin(config: ClientConfig): Promise {
const params = new URLSearchParams({
@@ -95,9 +111,9 @@ async function dsLogin(config: ClientConfig): Promise {
format : 'sid',
})
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`)
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`Synology a répondu HTTP ${res.status} : vérifiez l'URL de DSM`)
const data = await res.json()
- if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
+ if (!data.success) throw new Error(`Connexion refusée par Synology : ${dsErrorText(data.error, AUTH_ERRORS)}`)
return data.data.sid
}
@@ -111,13 +127,13 @@ async function dsRequest(
): Promise {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
const data = await res.json()
- if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
+ if (!data.success) throw new Error(`Erreur Download Station : ${dsErrorText(data.error, TASK_ERRORS)}`)
return data.data
}
-// ─── Driver ────────────────────────────────────────────────────────────────────
+// ─── Client ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = {
definition: {
@@ -125,13 +141,13 @@ const DS: TorrentClientDriver = {
label : 'Synology Download Station',
filterByManagedHashes: true,
fields : [
- { key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
- { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
- { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
- { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
- { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
- { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
- { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
+ { key: 'url', label: 'URL de DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
+ { key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'admin', required: true },
+ { key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
+ { key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
+ { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
+ { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/volume1/downloads', required: false },
+ { key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -142,8 +158,8 @@ const DS: TorrentClientDriver = {
logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('synology-ds', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -175,8 +191,7 @@ const DS: TorrentClientDriver = {
const tasks: any[] = data?.tasks ?? []
- // Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
- // Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
+ // Pas de catégories chez Synology : filtre sur le dossier de téléchargement s'il est renseigné
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const filtered = normalizedSavePath
? tasks.filter(t => {
@@ -198,7 +213,6 @@ const DS: TorrentClientDriver = {
String(t.id ?? ''),
)
- // Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
const synoIdStr = String(t.id ?? '').toLowerCase()
if (!hash.startsWith('dbid_') && synoIdStr) {
_hashToSynoId.set(hash, synoIdStr)
@@ -238,17 +252,16 @@ const DS: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null)
- logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
+ logger.warn('synology-ds', 'Download Station ne permet pas de choisir un fichier : torrent complet téléchargé')
- // Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
if (options?.infohash) {
storeUriHash(url, options.infohash)
- logger.debug('synology-ds', `URI→hash mémorisé : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
+ logger.debug('synology-ds', `Hash mémorisé pour l'URI : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
}
const sid = await dsLogin(config)
- // Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
+ // IDs existants avant l'ajout, pour repérer la nouvelle tâche
const beforeIds = new Set()
try {
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
@@ -265,22 +278,20 @@ const DS: TorrentClientDriver = {
})
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`Download Station a répondu HTTP ${res.status}`)
return res.json()
}
let data = await doAdd(true)
- // Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
+ // Code 403 : dossier introuvable sur le NAS, nouvel essai sans destination
if (!data.success && data.error?.code === 403 && config.savePath) {
- logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
+ logger.warn('synology-ds', `Dossier « ${config.savePath} » introuvable sur le NAS (code 403) : ajout dans le dossier par défaut`)
data = await doAdd(false)
}
- if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
+ if (!data.success) throw new Error(`Download Station a refusé le torrent : ${dsErrorText(data.error, TASK_ERRORS)}`)
- // Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
- // et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
if (options?.infohash) {
try {
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
@@ -288,12 +299,12 @@ const DS: TorrentClientDriver = {
if (newTask) {
const synoId = String(newTask.id).toLowerCase()
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
- logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
+ logger.debug('synology-ds', `ID Synology ${synoId} associé à ${options.infohash.slice(0, 8)}…`)
}
} catch {}
}
- logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
+ logger.info('synology-ds', `Torrent ajouté${config.savePath ? ` (dossier : ${config.savePath})` : ''}`)
},
async getFiles(config, hash) {
@@ -358,7 +369,7 @@ const DS: TorrentClientDriver = {
_sid : sid,
})
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`)
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`Échec de la suppression (HTTP ${res.status})`)
logger.info('synology-ds', `Torrent ${found.id} supprimé`)
},
}
diff --git a/server/torrent-clients/transmission.ts b/server/torrent-clients/transmission.ts
index 1aa2e28..119ff26 100644
--- a/server/torrent-clients/transmission.ts
+++ b/server/torrent-clients/transmission.ts
@@ -1,15 +1,10 @@
-/**
- * Transmission Driver
- */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
- // Transmission status codes :
- // 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
- // 4 = downloading, 5 = seed queued, 6 = seeding
+ // 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
@@ -38,17 +33,17 @@ async function trRequest(
body : JSON.stringify({ method, arguments: args }),
})
- // Transmission renvoie 409 avec le session ID à la première requête
+ // Première requête : 409 avec l'identifiant de session
if (res.status === 409) {
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
- if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
+ if (!newSessionId) throw new Error('Identifiant de session absent de la réponse de Transmission (HTTP 409)')
return trRequest(config, method, args, newSessionId)
}
- if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
+ if (!res.ok) throw new Error(`Transmission a répondu HTTP ${res.status}`)
const data = await res.json()
- if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
+ if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
return { ...data, sessionId }
}
@@ -59,10 +54,6 @@ async function trGetIdByHash(config: ClientConfig, hash: string): Promise t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
}
-/**
- * Attend que les métadonnées soient disponibles (polling 500 ms),
- * puis applique les priorités : fichier cible voulu, tous les autres ignorés.
- */
async function trApplyFilePriority(
config : ClientConfig,
hash : string,
@@ -84,11 +75,11 @@ async function trApplyFilePriority(
'files-wanted' : [fileIndex],
'files-unwanted' : unwanted,
})
- logger.info('transmission', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
+ logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
return
} catch {}
}
- throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
+ throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
const TR: TorrentClientDriver = {
@@ -97,12 +88,12 @@ const TR: TorrentClientDriver = {
label : 'Transmission',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
- { key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
+ { key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'transmission', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
- { key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
- { key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
- { key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
+ { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
+ { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
+ { key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
],
},
@@ -113,8 +104,8 @@ const TR: TorrentClientDriver = {
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
- const msg = err instanceof Error ? err.message : 'Erreur inconnue'
- logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
+ const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
+ logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -123,10 +114,10 @@ const TR: TorrentClientDriver = {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
- logger.debug('transmission', `Healthcheck OK — version ${version}`)
+ logger.debug('transmission', `Client en ligne (version ${version})`)
return { online: true, version }
} catch (err) {
- logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
+ logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -136,7 +127,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
- 'files', // bytesCompleted + length par fichier (fileStats n'a pas 'length')
+ 'files', // bytesCompleted et length par fichier (fileStats n'a pas length)
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
@@ -184,21 +175,21 @@ const TR: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) {
- logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
+ logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
} else {
- logger.info('transmission', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
+ logger.info('transmission', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
}
trApplyFilePriority(config, hash, options.file_index).catch(err =>
- logger.warn('transmission', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
+ logger.warn('transmission', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
- logger.info('transmission', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
+ logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
- // Transmission identifie les torrents par ID numérique — on cherche via la liste
+ // Torrents identifiés par ID numérique : recherche dans la liste
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents: any[] = data.arguments?.torrents ?? []
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
diff --git a/server/torrent-clients/utorrent.ts b/server/torrent-clients/utorrent.ts
index 2e9fc44..2ed141b 100644
--- a/server/torrent-clients/utorrent.ts
+++ b/server/torrent-clients/utorrent.ts
@@ -1,25 +1,19 @@
-/**
- * uTorrent Driver
- */
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js'
import { logger } from '../logger.js'
-// uTorrent status flags (bitmask) :
-// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
-// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
+// Drapeaux d'état : 1 démarré, 2 vérification, 4 démarrage après vérification, 8 vérifié, 16 erreur, 32 pause, 64 file d'attente, 128 chargé
function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error'
if (status & 32) return 'paused'
if (status & 2) return 'checking'
- // Démarré + chargé + vérifié → on regarde le ratio de progression
+ // Démarré et vérifié : en cours (le seeding est déduit de la progression)
if (status & 1 && status & 8) return 'downloading'
- if (status & 64) return 'downloading' // queued
+ if (status & 64) return 'downloading'
return 'unknown'
}
-// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: ClientConfig, hash: string): Promise {
@@ -31,10 +25,7 @@ async function utTorrentExists(config: ClientConfig, hash: string): Promise setTimeout(r, 500))
try {
@@ -52,24 +42,22 @@ async function utApplyFilePriority(
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
- // Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
- // Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
- logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
+ logger.info('utorrent', `Fichier n° ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
- throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
+ throw new Error(`Délai dépassé : fichiers du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
}
async function utGetSession(config: ClientConfig): Promise {
@@ -79,11 +67,12 @@ async function utGetSession(config: ClientConfig): Promise {
}
const res = await clientFetch(config, `${config.url}/gui/token.html`, { headers })
- if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
+ if (res.status === 401) throw new Error('uTorrent a refusé la connexion : vérifiez le nom d\'utilisateur et le mot de passe')
+ if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status} : vérifiez l'URL de la WebUI`)
const text = await res.text()
const match = text.match(/