refacto: réécriture des textes, messages et commentaires

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 6a3cb21adf
commit 586eccbbb3
118 files changed
+1621 -2159

No files matched your search

+5 -11
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+1 -1
View File
@@ -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"]
+187 -153
View File
@@ -11,7 +11,7 @@
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
Inspiré de Radarr et Sonarr, pour les éditions Kai et Yabai
![Banner](.github/images/banner.svg)
@@ -21,77 +21,91 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
## Aperçu
<!-- [img] : vue catalogue complète — grille des affiches avec les barres d'état en bas de chaque affiche (couleurs : dispo / en cours / importé), barre de recherche visible -->
![Catalogue](.github/images/catalog.png)
![Accueil](.github/images/dashboard.webp)
> 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.
---
<!-- [img] : vue série — détail des saisons et épisodes avec badges VOSTFR/MULTI + drapeaux, boutons de téléchargement avec dropdown multi-sources, barres de progression par épisode -->
![Série](.github/images/serie.png)
![Médiathèque](.github/images/catalog.webp)
> 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.
---
<!-- [img] : vue téléchargements — liste des torrents actifs avec barres de progression, état d'organisation, badges erreur au hover -->
![Téléchargements](.github/images/downloads.png)
![Série](.github/images/serie.webp)
> 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é](.github/images/downloads.webp)
> Activité : un torrent par ligne, du téléchargement à l'import, avec le détail des erreurs et un bouton pour relancer l'import.
---
<p align="center">
<img src=".github/images/mobile-dashboard.webp" width="260" alt="Accueil sur mobile">
<img src=".github/images/mobile-serie.webp" width="260" alt="Fiche d'une série sur mobile">
<img src=".github/images/mobile-activity.webp" width="260" alt="Activité sur mobile">
</p>
> 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.
<!-- [img] : page paramètres — formulaire de configuration client torrent, chemins, mode d'organisation, toggles autoImport / NFO / désimport auto -->
![Paramètres](.github/images/settings.png)
![Assistant de configuration](.github/images/setup.webp)
Tous ces réglages restent modifiables dans les paramètres, et l'assistant peut être relancé.
![Paramètres](.github/images/settings.webp)
---
## 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
<!-- [img] : page logs — liste des événements avec filtres niveau/source, bouton clear, affichage compact -->
![Logs](.github/images/logs.png)
![Journaux](.github/images/logs.webp)
> 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>` (token visible dans **Profil → Token API**)
**Authentification** : `Authorization: Bearer <jeton>` (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 <token>
Authorization: Bearer <jeton>
```
```json
@@ -308,19 +335,23 @@ Authorization: Bearer <token>
]
```
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 <token>
Authorization: Bearer <jeton>
```
```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 <token>
```http
POST /api/v1/requests
Authorization: Bearer <token>
Authorization: Bearer <jeton>
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
- [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
+14 -14
View File
@@ -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
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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;600;700;800&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#13100c" />
<title>Fankarr</title>
<title>FanKarr</title>
</head>
<body>
<div id="app"></div>
+21 -36
View File
@@ -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
@@ -66,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' })
}
}
@@ -96,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)
}
@@ -115,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')
@@ -131,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' })
}
}
@@ -150,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' })
}
}
@@ -159,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
@@ -194,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 <token>) */
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) {
+3 -10
View File
@@ -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;
+1 -6
View File
@@ -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<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
+36 -43
View File
@@ -71,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)
@@ -85,14 +85,11 @@ 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',
@@ -108,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)
@@ -124,7 +121,7 @@ app.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter)
// ── 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,7 +181,7 @@ 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()
@@ -201,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 =>
@@ -210,25 +206,25 @@ 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}`)
}
}
@@ -239,45 +235,42 @@ server.listen(PORT, async () => {
}, 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)
})
+5 -10
View File
@@ -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 === undefined ? 1 : opts.maxUses, // null = illimité
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)
+2 -6
View File
@@ -22,7 +22,6 @@ export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
}
// Date de la dernière lecture d'une clé
export function cacheFetchedAt(key: string): string | null {
const entry = _cache.get(key)
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
@@ -32,10 +31,7 @@ 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<any> {
if (!force) {
const cached = cacheGet(urlPath)
@@ -44,7 +40,7 @@ export async function githubGet(urlPath: string, force = false): Promise<any> {
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
+7 -11
View File
@@ -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<JellyfinUser[]> {
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' }
+4 -4
View File
@@ -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()
+2 -2
View File
@@ -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
}
+1 -14
View File
@@ -1,9 +1,6 @@
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
/**
* Torrents à envoyer pour une portée de demande.
* episodes non vide → épisodes ciblés ; sinon seasons non vide → saisons ciblées ; sinon toute la série puis voila
*/
/** 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<string>()
@@ -20,16 +17,11 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
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
*/
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)
@@ -48,10 +40,8 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
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 ?? [])
}
}
@@ -60,12 +50,10 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
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 ?? [])
}
@@ -75,7 +63,6 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
return toDownload
}
// Épisodes uniques concernés par la même portée
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
const keys = new Set<string>()
for (const season of serieData.seasons ?? []) {
+15 -28
View File
@@ -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<number, SyncedSerie>
@@ -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<string, Record<string, any>> {
try {
@@ -144,7 +136,7 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
return ids
}
// ── Logique de sync ───────────────────────────────────────────
// ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
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<number>()
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<number>()
// 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 }
}
-1
View File
@@ -1,4 +1,3 @@
// Cadence de l'import automatique
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
export const autoImportSchedule = {
+5 -23
View File
@@ -6,7 +6,7 @@ const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
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()
}
@@ -48,15 +48,14 @@ export async function resolveSerieData(serieId: number, action?: string): Promis
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises 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)})`)
logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(err)})`)
return null
}
}
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 +120,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 +136,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 +151,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<number, any>()
for (const ep of episodes) {
@@ -226,8 +209,7 @@ export function computeSerieDownloadState(
}
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
+6 -19
View File
@@ -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<string, any>
}
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<string, any>) {
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
// Console
const prefix = `[${source}]`
if (level === 'error') console.error(prefix, msg, meta ?? '')
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
else if (level === 'info') console.log(prefix, msg, meta ?? '')
// Disque — pas les debug en prod
if (level === 'debug' && IS_PROD) return
writeLine(entry)
}
@@ -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 [] }
+2 -2
View File
@@ -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
}
+39 -69
View File
@@ -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<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>>
// ─── 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<Buffer> {
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
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<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
@@ -306,7 +288,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
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<void>
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<string> | 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}`)
}
}
+53 -79
View File
@@ -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'
@@ -63,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)
@@ -72,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)
@@ -96,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<string, {
hashes : string[]
@@ -115,7 +110,6 @@ export async function scanMediaPath(
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
// Helper : indexer par nom sans extension
const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '')
if (!base) return
@@ -134,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
@@ -151,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)
@@ -166,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<string>()
let noMatch = 0
@@ -205,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()) {
@@ -221,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
}
@@ -239,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})`)
}
}
}
@@ -251,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(
@@ -270,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) {
@@ -280,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]
@@ -292,12 +280,12 @@ export async function scanMediaPath(
}
lastScan = { at: new Date().toISOString(), ...result }
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` : ''}`)
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<any[]>,
@@ -306,16 +294,16 @@ export async function autoOrganizeAll(
): Promise<void> {
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
}
@@ -354,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,
@@ -384,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<string, Map<string, number>>()
const validIds = new Set<number>()
@@ -421,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 }
}
@@ -442,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 } {
@@ -497,7 +474,7 @@ export function dedupeOrganizedEpisodes(
return { removed: 0 }
}
// episode_id → [{ hash, entry }]
// episode_id : [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
@@ -517,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[],
@@ -539,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 }
}
@@ -565,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
)
@@ -583,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)
@@ -596,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}`)
}
}
}
@@ -609,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 }
}
@@ -626,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: [] }
@@ -647,16 +621,16 @@ export async function organizeTorrent(
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
+14 -27
View File
@@ -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<number>()
@@ -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<number>()
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,7 +157,7 @@ export function deleteRequest(id: string): void {
writeRequests(requests.filter(r => r.id !== id))
}
/** Retire un utilisateur surune demande en attente et la demande disparaît s'il était seul demandeur */
/** 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)
@@ -181,12 +168,12 @@ export function withdrawRequest(id: string, userId: string): { deleted: boolean;
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}"`)
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}"`)
logger.info('requests', `« ${username} » retiré de la demande « ${req.serieName} »`)
return { deleted: false, request: req }
}
+6 -6
View File
@@ -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' })
}
})
@@ -76,8 +76,8 @@ router.get('/downloads', requireAuth, async (_req, res) => {
}))
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' })
}
})
+84 -90
View File
@@ -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 {
@@ -83,13 +85,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')
@@ -125,12 +127,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 => {
@@ -143,16 +144,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 => {
@@ -164,7 +162,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) }
@@ -176,10 +174,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 = {
@@ -190,16 +188,16 @@ 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'
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
logger.error('api', `Échec de l'import manuel de « ${srcFilename} » : ${msg}`)
}
}
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
}
}
@@ -222,9 +220,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}`)
}
})()
}
@@ -232,7 +230,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 {
@@ -266,20 +264,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 ?? []) {
@@ -302,30 +300,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<string>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id))
@@ -356,38 +354,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<string>(season.episodes?.map((e: any) => String(e.id)) ?? [])
@@ -413,17 +409,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] })
@@ -437,7 +433,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[] = []
@@ -446,7 +442,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
@@ -454,16 +449,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()
@@ -478,8 +473,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()
@@ -508,16 +502,16 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
: []
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
} 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 }
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper GitHub ou API Fankai injoignable), réessayez plus tard' }); return }
let removed = 0
try {
updateOrganized(data => {
@@ -529,19 +523,19 @@ router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
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.json illisible' }); return }
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()
@@ -553,8 +547,8 @@ router.get('/organized-folders', requireAdmin, async (_req, res) => {
}
res.json(result)
} catch (err) {
logger.error('api', `organized-folders échoué : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
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 })
}
})
@@ -565,7 +559,7 @@ 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 others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath)
const current = [...serieFolders(sd, organized, mediaPath, others)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
@@ -574,13 +568,13 @@ router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
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 }
@@ -595,7 +589,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<string, string>()
@@ -616,19 +610,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
@@ -636,7 +630,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) {
@@ -655,23 +649,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 })
}
}
@@ -681,11 +675,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'])
@@ -703,10 +697,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)
}
}
@@ -714,7 +708,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 })
})
+7 -7
View File
@@ -25,7 +25,7 @@ router.post('/invites', requireAdmin, (req, res) => {
})
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' })
}
})
+5 -9
View File
@@ -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<string, string> = {}
@@ -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' })
}
})
+2 -2
View File
@@ -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' })
})
+6 -8
View File
@@ -19,8 +19,8 @@ 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' })
}
})
@@ -40,13 +40,11 @@ router.post('/organize/recent/clear', requireAuth, (_req, res) => {
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)
@@ -55,8 +53,8 @@ router.post('/organize', requireAuth, async (req, res) => {
}
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' })
}
})
+36 -36
View File
@@ -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<any> {
const res = await fetch(url, {
...options,
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
...(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<string, string> = { '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 })
}
})
+14 -24
View File
@@ -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 <token>)' }); return
res.status(401).json({ error: 'Jeton d\'API requis (Authorization: Bearer <jeton>)' }); 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))
})
+16 -31
View File
@@ -14,7 +14,6 @@ import { logger } from '../logger.js'
const router = Router()
// GET /api/requests — admin voit tout, user voit les siennes
router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin'
? readRequests()
@@ -37,7 +36,6 @@ async function withScope(list: SerieRequest[]) {
}))
}
// GET /api/requests/pending-count — pour le widget dashboard
router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin'
? getPendingCount()
@@ -45,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) : []
@@ -63,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'
@@ -74,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,
@@ -82,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 {
@@ -107,28 +100,23 @@ 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<void> {
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
}
@@ -138,7 +126,7 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
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
}
@@ -147,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) {
@@ -166,22 +153,20 @@ router.delete('/requests', requireAdmin, (_req, res) => {
res.json({ deleted: all.length })
})
// DELETE /api/requests/:id/mine — retire l'utilisateur d'une demande en attente
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' })
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux' })
}
})
// DELETE /api/requests/:id — admin seulement
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' })
}
})
+7 -13
View File
@@ -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)
+5 -11
View File
@@ -17,7 +17,6 @@ import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router()
// Séries récemment ajoutées à la médiathèque
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))
@@ -52,8 +51,8 @@ router.get('/series', requireAuth, async (_req, res) => {
}
})})
} 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' })
}
})
@@ -80,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<string, { torrent_url: string; magnet: string; type: string; raw: string; fankai: boolean; torrent_name: string | null }>()
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 })
@@ -126,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<string>()
for (const pathEntry of (ep.paths ?? [])) {
@@ -175,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
@@ -201,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' })
}
})
+33 -23
View File
@@ -15,7 +15,17 @@ 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) })
}
})
@@ -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,14 +172,14 @@ 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 })
}
})
@@ -180,17 +190,17 @@ router.get('/scan', requireAuth, (_req, res) => {
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 mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
+8 -8
View File
@@ -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))
})
+7 -13
View File
@@ -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' })
}
})
+2 -12
View File
@@ -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
+4 -4
View File
@@ -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
+5 -3
View File
@@ -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') {
+28 -59
View File
@@ -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<string, SessionEntry>()
@@ -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<string> {
const url = delugeUrl(config)
@@ -78,22 +65,19 @@ async function delugeLogin(config: ClientConfig): Promise<string> {
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<string, any> = 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<string, unknown> = {}
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 []
+45 -73
View File
@@ -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<typeof undiciFetch>[1]) as unknown as Promise<Response>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
// ─── Registre ─────────────────────────────────────────────────────────────────
const drivers = new Map<string, TorrentClientDriver>()
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<string, string[]>) {
} catch {}
}
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
function getManagedSet(clientUuid: string): Set<string> | 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<string, BackoffEntry>()
// 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<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
@@ -312,11 +295,9 @@ export function getClientCooldowns(): Record<string, { failCount: number; nextRe
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// ─── Correspondance des chemins ───────────────────────────────────────────────
// FIX : remplace le préfixe remotePath par localPath dans save_path
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
// Remplace le préfixe remotePath par localPath (ex. /downloads/x devient /mnt/nas/downloads/x)
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
@@ -325,13 +306,13 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
// ─── Envoi d'un téléchargement ────────────────────────────────────────────────
export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
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<string, string>): Map<string, string> {
const index = new Map<string, string>()
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,
+39 -62
View File
@@ -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<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
@@ -48,7 +43,7 @@ async function qbLogin(config: ClientConfig): Promise<string> {
})
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<string,
}
}
/**
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
* puis applique les priorités (désactive tout sauf la cible).
*/
async function qbApplyFilePriority(
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
// 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<string, any[]>()
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<string> = 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)' : ''}`)
},
}
+16 -37
View File
@@ -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`)
+28 -52
View File
@@ -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 `<value><string>${val}</string></value>`
@@ -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(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
if (faultMatch) throw new Error(`Erreur rTorrent : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\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('<array>')) {
const items = [...inner.matchAll(/<value>([\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<bool
}
}
/**
* Attend les métadonnées via polling rapide (300 ms), applique les priorités.
* Le torrent est ajouté avec load.start — il fetch les métadonnées activement via DHT/peers.
* Pendant le fetch (état "métadonnées en cours"), aucun byte de fichier n'est téléchargé.
* On set les priorités dès que les fichiers sont disponibles, avant que le download réel commence.
*/
/** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
async function rtApplyFilePriority(
config : ClientConfig,
hash : string,
fileIndex: number,
): Promise<void> {
// 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<string, any[]>()
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 {
+60 -49
View File
@@ -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<string, string>()
function loadUriHashMap(): Record<string, string> {
@@ -38,7 +30,7 @@ const _uriToHash: Map<string, string> = (() => {
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<number, string> = {
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<number, string> = {
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<number, string>): string {
const code = error?.code
return table[code] ? `${table[code]} (code ${code})` : `code ${code ?? 'inconnu'}`
}
async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({
@@ -95,9 +111,9 @@ async function dsLogin(config: ClientConfig): Promise<string> {
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<any> {
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<string>()
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é`)
},
}
+21 -30
View File
@@ -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<number
return torrents.find((t: any) => 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())
+27 -43
View File
@@ -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<boolean> {
@@ -31,10 +25,7 @@ async function utTorrentExists(config: ClientConfig, hash: string): Promise<bool
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
/** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
async function utApplyFilePriority(
config : ClientConfig,
hash : string,
@@ -43,7 +34,6 @@ async function utApplyFilePriority(
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => 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<UTSession> {
@@ -79,11 +67,12 @@ async function utGetSession(config: ClientConfig): Promise<UTSession> {
}
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(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
if (!match) throw new Error('Réponse inattendue de uTorrent : vérifiez l\'URL de la WebUI')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
@@ -104,15 +93,12 @@ async function utRequest(
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
if (!res.ok) throw new Error(`uTorrent a répondu HTTP ${res.status}`)
return res.json()
}
function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent :
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
// Tableau par torrent : [hash, status, name, size, progress, downloaded, uploaded, ratio, ul_speed, dl_speed, eta, label, …]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
@@ -122,7 +108,7 @@ function mapTorrent(t: any[]): TorrentInfo {
size,
downloaded,
uploaded : uploaded ?? 0,
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // uTorrent ratio est x1000
ratio : Math.round(((ratio ?? 0) / 1000) * 100) / 100, // ratio × 1000
speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0,
eta : eta ?? -1,
@@ -136,13 +122,13 @@ const UT: TorrentClientDriver = {
id : 'utorrent',
label : 'uTorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', 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 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 },
],
},
@@ -153,8 +139,8 @@ const UT: TorrentClientDriver = {
logger.info('utorrent', `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('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('utorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
@@ -164,10 +150,10 @@ const UT: TorrentClientDriver = {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue'
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
logger.debug('utorrent', `Client en ligne (build ${version})`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
logger.debug('utorrent', `Client injoignable : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
@@ -185,7 +171,6 @@ const UT: TorrentClientDriver = {
.map(t => {
const info = mapTorrent(t)
// Récupérer le save_path depuis les properties si disponible
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? ''
@@ -201,9 +186,9 @@ const UT: TorrentClientDriver = {
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
@@ -219,18 +204,17 @@ const UT: TorrentClientDriver = {
method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (!res.ok) throw new Error(`Échec de l'ajout (HTTP ${res.status})`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
logger.info('utorrent', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
logger.warn('utorrent', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
logger.info('utorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
}
// Appliquer le label/catégorie si défini
if (config.category) {
setTimeout(async () => {
try {
+13 -18
View File
@@ -1,7 +1,3 @@
/**
* Gestion des utilisateurs multi-user
* Migration automatique depuis l'ancien auth.json mono-user
*/
import fs from 'fs'
import path from 'path'
@@ -27,11 +23,11 @@ const USERS_PATH = path.join(DATA_DIR, 'users.json')
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
// ── Migration mono → multi-user ───────────────────────────────
// ── Migration depuis auth.json ────────────────────────────────
export function migrateIfNeeded(): void {
if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user
if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré
if (fs.existsSync(USERS_PATH)) return
if (!fs.existsSync(LEGACY_PATH)) return
try {
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
@@ -47,13 +43,13 @@ export function migrateIfNeeded(): void {
createdAt : new Date().toISOString(),
}
writeUsers([admin])
logger.info('users', `Migration auth.json → users.json réussie pour "${legacy.username}"`)
logger.info('users', `Migration de auth.json vers users.json réussie pour « ${legacy.username} »`)
} catch (err) {
logger.error('users', `Échec migration auth.json : ${err instanceof Error ? err.message : err}`)
logger.error('users', `Échec de la migration de auth.json : ${err instanceof Error ? err.message : err}`)
}
}
// ── Helpers fichier ───────────────────────────────────────────
// ── Fichier des utilisateurs ──────────────────────────────────
export function readUsers(): User[] {
try {
@@ -74,18 +70,17 @@ export function findById(id: string): User | undefined { return read
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
/** Sérialise sans passwordHash pour les réponses API */
export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user
return safe
}
// ── CRUD ──────────────────────────────────────────────────────
// ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${username}" déjà utilisé`)
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
}
const user: User = {
id : crypto.randomUUID(),
@@ -97,7 +92,7 @@ export function createUser(username: string, password: string, role: UserRole =
tourSeenAt : null,
}
writeUsers([...users, user])
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`)
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user
}
@@ -113,7 +108,7 @@ export function updateUser(
if (changes.username !== undefined && changes.username !== user.username) {
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
throw new Error(`Nom d'utilisateur "${changes.username}" déjà utilisé`)
throw new Error(`Nom d'utilisateur « ${changes.username} » déjà utilisé`)
}
user.username = changes.username
}
@@ -121,7 +116,7 @@ export function updateUser(
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Utilisateur "${user.username}" mis à jour`)
logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
return user
}
@@ -130,7 +125,7 @@ export function deleteUser(id: string): void {
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
writeUsers(users.filter(u => u.id !== id))
logger.info('users', `Utilisateur "${user.username}" supprimé`)
logger.info('users', `Utilisateur « ${user.username} » supprimé`)
}
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
@@ -142,7 +137,7 @@ export function changePassword(id: string, currentPassword: string, newPassword:
}
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
writeUsers(users)
logger.info('users', `Mot de passe changé pour "${user.username}"`)
logger.info('users', `Mot de passe changé pour « ${user.username} »`)
}
export function markLogin(id: string): string | null {
+3 -4
View File
@@ -48,7 +48,7 @@ export interface Torrent {
url: string
}
// Parse "Nom@url|Nom@url" → Torrent[]
// "Nom@url|Nom@url" en liste de torrents
export function parseTorrents(raw: string | null): Torrent[] {
if (!raw) return []
return raw.split('|').flatMap(item => {
@@ -58,7 +58,6 @@ export function parseTorrents(raw: string | null): Torrent[] {
})
}
// Préfixe les images relatives
export function imageUrl(path: string | null): string | null {
if (!path) return null
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
@@ -67,14 +66,14 @@ export function imageUrl(path: string | null): string | null {
export async function fetchProductions(): Promise<Production[]> {
const res = await fetch(`${BASE}/productions`)
if (!res.ok) throw new Error('Erreur chargement catalogue')
if (!res.ok) throw new Error('Impossible de charger le catalogue.')
const data = await res.json()
return data.pageProps.data
}
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
if (!res.ok) throw new Error('Erreur chargement production')
if (!res.ok) throw new Error('Impossible de charger la série.')
const data = await res.json()
return data.pageProps.data
}
+3 -3
View File
@@ -167,7 +167,7 @@ body {
.pill-err { @apply border-err/35 text-err; }
.pill-muted { @apply border-border-light text-muted; }
/* Filtres et onglets compteurs */
/* Filtres et onglets à compteur */
.chip {
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
@@ -210,7 +210,7 @@ body {
@apply translate-x-5 bg-on-accent;
}
/* Sélecteur */
/* Sélecteur segmenté */
.segmented {
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
}
@@ -236,7 +236,7 @@ body {
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
}
/* Menus déroulants et pop */
/* Menus déroulants et modales */
.menu {
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
}
+5 -11
View File
@@ -3,7 +3,6 @@
<div class="modal-backdrop" @click.self="$emit('cancel')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
<!-- Header -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
<div class="min-w-0">
<p class="card-title">Choisir un dossier</p>
@@ -12,7 +11,6 @@
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
<House :size="15" />
@@ -25,22 +23,20 @@
aria-label="Chemin"
/>
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
OK
Aller
</button>
</div>
<!-- Retour parent -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button
@click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
>
<ArrowLeft :size="14" />
..
Dossier parent
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
@@ -59,10 +55,8 @@
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
<span class="text-meta text-muted truncate">{{ current }}</span>
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
@@ -93,7 +87,7 @@ function selectCurrent() {
}
function joinPath(base: string, name: string): string {
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
// Lecteur Windows (ex. "C:\") : déjà absolu
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.replace(/[\\/]+$/, '')
@@ -107,7 +101,7 @@ async function navigateTo(p: string) {
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
error.value = (await res.json()).error ?? 'Erreur'
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
return
}
const data = await res.json()
@@ -117,7 +111,7 @@ async function navigateTo(p: string) {
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
error.value = 'Impossible de lire ce dossier.'
} finally {
loading.value = false
}
+31 -45
View File
@@ -3,47 +3,42 @@
<div class="modal-backdrop" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
<!-- En-tête -->
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div class="min-w-0 flex flex-col gap-1">
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">— {{ serieName }}</span></h3>
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">· {{ serieName }}</span></h3>
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
</div>
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Chargement -->
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Scan du dossier…</p>
<p class="text-body">Analyse du dossier…</p>
</div>
<!-- Dossier vide / introuvable -->
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
<p class="text-meta text-muted">
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
</p>
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Rescanner</button>
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</button>
</div>
<!-- Erreur -->
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-body text-err">{{ scanError }}</p>
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
</div>
<!-- Liste des fichiers -->
<template v-else-if="items.length > 0 && step !== 'done'">
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
<p class="text-meta text-muted">
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
· <span class="text-accent">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
<span v-if="unmatchedCount > 0"> · {{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span>
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
</p>
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Rescanner</button>
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
</div>
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
@@ -80,7 +75,7 @@
@change="onEpisodeChange(i)"
:aria-label="`Épisode associé à ${item.file.name}`"
>
<option :value="null">— Non associé —</option>
<option :value="null">Aucun épisode</option>
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option
v-for="ep in season.episodes"
@@ -88,8 +83,8 @@
:value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
>
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
</option>
</optgroup>
</select>
@@ -108,14 +103,13 @@
:disabled="newMatchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
</button>
</div>
</div>
</template>
<!-- Résultat -->
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
<span
class="w-12 h-12 rounded-full flex items-center justify-center"
@@ -127,16 +121,16 @@
<div class="flex flex-col gap-1">
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
<p class="text-body text-secondary">
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
<span v-if="importResult.errors.length > 0" class="text-err">
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
· {{ plural(importResult.errors.length, 'erreur') }}
</span>
</p>
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
<p class="tag-label text-err mb-1.5">Erreurs</p>
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
<span class="text-primary">{{ e.file }}</span> — {{ e.error }}
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
</p>
</div>
<div class="flex gap-2.5">
@@ -155,6 +149,7 @@ import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
import { matchEpisodeFile } from '@/utils/episode-match'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -171,7 +166,7 @@ const emit = defineEmits<{
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const step = ref<'list' | 'done'>('list')
const scanning = ref(false)
const importing = ref(false)
@@ -189,7 +184,7 @@ const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
interface FileItem {
file : { name: string; path: string; size: number }
episode_id : number | null
originalEpisodeId : number | null // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
hash : string | null
alreadyImported : boolean
willRename : boolean
@@ -197,12 +192,12 @@ interface FileItem {
const items = ref<FileItem[]>([])
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ──────────────────────────────────────────────────
// ── Utilitaires ──
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
@@ -236,13 +231,6 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function onEpisodeChange(i: number) {
const item = items.value[i]
if (!item) return
@@ -252,8 +240,7 @@ function onEpisodeChange(i: number) {
item.alreadyImported = false
item.willRename = newEpId !== null
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
// Le fichier choisi remplace ceux déjà importés pour cet épisode
if (newEpId !== null) {
items.value = items.value.filter(other =>
other === item || !(other.episode_id === newEpId && other.alreadyImported)
@@ -261,7 +248,7 @@ function onEpisodeChange(i: number) {
}
}
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function scan() {
scanning.value = true
scanError.value = ''
@@ -271,7 +258,7 @@ async function scan() {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
scanError.value = data.error ?? 'Erreur lors du scan'
scanError.value = data.error ?? "Impossible d'analyser le dossier."
return
}
const data = await res.json()
@@ -281,7 +268,6 @@ async function scan() {
const organizedByPath = buildOrganizedByPath()
items.value = files.map(f => {
// Vérifier si déjà importé via dest_path
const existingEpId = organizedByPath.get(f.path) ?? null
if (existingEpId) {
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
@@ -291,7 +277,7 @@ async function scan() {
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
})
} catch {
scanError.value = 'Impossible de contacter le serveur'
scanError.value = 'Impossible de contacter le serveur.'
} finally {
scanning.value = false
}
@@ -306,7 +292,7 @@ async function doImport() {
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
if (payload.length === 0) {
importError.value = 'Aucun nouveau fichier à importer'
importError.value = 'Aucun nouveau fichier à importer.'
importing.value = false
return
}
@@ -320,26 +306,26 @@ async function doImport() {
})
const data = await res.json()
if (!res.ok) {
importError.value = data.error ?? 'Erreur inconnue'
toast(data.error ?? 'Erreur lors de l\'import', 'error')
importError.value = data.error ?? "Impossible d'importer les fichiers."
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
return
}
importResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) {
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
}
if ((data.errors ?? []).length > 0) {
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
}
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
// Le parent recharge les épisodes importés
emit('imported')
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
importError.value = 'Impossible de contacter le serveur.'
toast('Impossible de contacter le serveur', 'error')
} finally {
importing.value = false
@@ -348,7 +334,7 @@ async function doImport() {
function backToList() {
step.value = 'list'
scan() // re-scan → les fichiers importés passent en "déjà importé"
scan() // nouvelle analyse pour marquer les fichiers importés
}
onMounted(scan)
-1
View File
@@ -28,7 +28,6 @@
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
<!-- Nbre de messages regroupés -->
<span
v-if="t.count && t.count > 1"
class="shrink-0 pill h-5 px-2 text-[10.5px]"
@@ -1,6 +1,5 @@
<template>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
<!-- Onglets à compteurs -->
<div class="chip-row">
<button
v-for="t in tabs" :key="t.value"
@@ -32,7 +31,7 @@
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
/>
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer le filtre" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
<X :size="13" />
</button>
</label>
@@ -46,7 +45,6 @@
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
</label>
<!-- Tri, colonnes, seeds -->
<div ref="optionsRef" class="relative">
<button
@click="optionsOpen = !optionsOpen"
@@ -79,7 +77,7 @@
<div class="h-px bg-hover my-1" />
<label class="menu-item cursor-pointer justify-between">
Seeds uniquement
Masquer les états inconnus
<button
type="button" role="switch" :aria-checked="seedingOnly"
@click="emit('update:seedingOnly', !seedingOnly)"
+16 -24
View File
@@ -2,7 +2,6 @@
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
<div class="flex items-center gap-4 flex-1 min-w-0">
<!-- Affiche -->
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
@@ -12,17 +11,15 @@
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
<!-- Nom, état, client -->
<div class="flex items-center gap-2.5 min-w-0">
<div class="relative group/eps min-w-0">
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
</p>
<!-- Épisodes du torrent -->
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
<div class="menu p-3 gap-0">
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
<p class="tag-label mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
<p class="tag-label mb-2">{{ plural(episodesWithProgress.length, 'épisode') }}</p>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
@@ -31,7 +28,7 @@
class="pill h-5 px-2 text-[10.5px]"
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
>
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
{{ ep.priority === 0 ? 'Exclu' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
</span>
</div>
</div>
@@ -43,20 +40,18 @@
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }}&nbsp;%</span>
</div>
<!-- Progression, vitesse, ETA -->
<template v-if="!isDone">
<div class="progress">
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
</div>
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">reste {{ formatDuration(torrent.eta) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0 && formatDuration(torrent.eta) !== '∞'">reste {{ formatDuration(torrent.eta) }}</span>
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
</div>
</template>
<!-- Terminé : erreurs d'import ou détails -->
<template v-else>
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
@@ -75,20 +70,19 @@
<span>
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
</span>
<span v-if="torrent.state === 'unknown'" class="text-accent">État non remonté par le client</span>
<span v-if="torrent.state === 'unknown'" class="text-accent">Le client n'indique pas l'état de ce torrent</span>
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
<span v-if="columns.uploaded && torrent.uploaded">↑ {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
<span v-if="columns.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
</div>
</template>
</div>
</div>
<!-- État d'import et actions -->
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
<template v-if="!isDone">
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import auto à la fin' : 'Import manuel à la fin') : 'Série non reconnue' }}</span>
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import automatique à la fin' : 'À importer une fois terminé') : 'Série non reconnue' }}</span>
</template>
<template v-else>
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
@@ -109,30 +103,29 @@
</button>
</template>
<!-- Supprimer -->
<div class="relative">
<button
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
:disabled="deleting"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
:title="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
:title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
>
<Trash2 :size="14" />
</button>
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
<p class="text-body text-primary font-medium">
{{ isDone ? 'Supprimer ce torrent ?' : 'Annuler ce téléchargement ?' }}
{{ isDone ? 'Supprimer ce torrent du client ?' : 'Arrêter ce téléchargement ?' }}
</p>
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
Supprimer aussi les fichiers
Supprimer aussi les fichiers du disque
</label>
<div class="flex gap-2">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
{{ deleting ? '…' : 'Confirmer' }}
{{ deleting ? 'Suppression…' : isDone ? 'Supprimer' : 'Arrêter' }}
</button>
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">Annuler</button>
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">{{ isDone ? 'Garder' : 'Continuer' }}</button>
</div>
</div>
</div>
@@ -145,7 +138,7 @@
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
import { formatDuration, formatRelative, formatSize, formatSpeed } from '@/utils/format'
import { formatDuration, formatRelative, formatSize, formatSpeed, plural } from '@/utils/format'
const props = defineProps<{
torrent : any
@@ -172,7 +165,7 @@ const isImported = computed(() => isDone.value && props.torrent.organizeState ==
const clientLine = computed(() => [
props.columns.client ? props.torrent.client_name : null,
isDone.value && props.columns.ratio ? `R ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
isDone.value && props.columns.ratio ? `Ratio ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
].filter(Boolean).join(' · '))
@@ -184,7 +177,6 @@ const errorSummary = computed(() => {
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
})
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
const episodesWithProgress = computed(() => {
const eps: any[] = props.torrent.episodes ?? []
if (!eps.length) return []
@@ -208,5 +200,5 @@ const badge = computed(() => ({
checking : { label: 'Vérification', class: 'pill-wait' },
error : { label: 'Erreur', class: 'pill-err' },
unknown : { label: 'État inconnu', class: 'pill-wait' },
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'Inconnu', class: 'pill-muted' })
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'État inconnu', class: 'pill-muted' })
</script>
-1
View File
@@ -13,7 +13,6 @@
<Search :size="19" :stroke-width="1.75" />
</button>
<!-- Menu du compte -->
<div ref="menuRef" class="relative">
<button
data-tour="help"
+1 -3
View File
@@ -40,7 +40,6 @@
</template>
</nav>
<!-- Nouvelle version disponible -->
<a
v-if="auth.isAdmin && updateAvailable"
:href="latestReleaseUrl"
@@ -51,12 +50,11 @@
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span class="flex-1 min-w-0">
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
<span class="block text-[11.5px] text-muted truncate">{{ latestVersion }} est disponible</span>
<span class="block text-[11.5px] text-muted truncate">Version {{ latestVersion }}</span>
</span>
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
</a>
<!-- Utilisateur et déconnexion -->
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
{{ initial }}
@@ -8,7 +8,7 @@
</div>
<div class="flex flex-col gap-[7px]">
<label for="reject-message" class="field-label">Motif (facultatif)</label>
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Raison du refus…" @keyup.enter="emit('confirm', message)" />
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Visible par l'invité" @keyup.enter="emit('confirm', message)" />
</div>
<div class="flex gap-2.5 justify-end">
<button @click="emit('close')" class="btn-ghost">Annuler</button>
+15 -11
View File
@@ -1,13 +1,16 @@
<template>
<section class="relative overflow-hidden">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
alt=""
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50 pointer-events-none"
/>
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
<section class="relative">
<!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
alt=""
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
/>
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
</div>
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
@@ -39,7 +42,7 @@
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
>{{ statusLabel(serie.status) }}</span>
<span v-if="episodeCount" class="text-body text-secondary">
<template v-if="seasonCount">{{ seasonCount }} saison{{ seasonCount > 1 ? 's' : '' }} · </template>{{ episodeCount }} épisode{{ episodeCount > 1 ? 's' : '' }}
<template v-if="seasonCount">{{ plural(seasonCount, 'saison') }} · </template>{{ plural(episodeCount, 'épisode') }}
</span>
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
<Rss :size="11" :stroke-width="2.5" /> Surveillée
@@ -75,7 +78,7 @@
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
/>
</div>
<span class="text-meta text-secondary">{{ organizedCount }} épisode{{ organizedCount > 1 ? 's' : '' }} {{ organizedLabel }}{{ organizedCount > 1 ? 's' : '' }} sur {{ episodeCount }}</span>
<span class="text-meta text-secondary">{{ plural(organizedCount, `épisode ${organizedLabel}`, `épisodes ${organizedLabel}s`) }} sur {{ episodeCount }}</span>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
@@ -92,6 +95,7 @@ import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
import { statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
defineProps<{
serie : any
+46 -85
View File
@@ -1,6 +1,5 @@
<template>
<section class="bg-card rounded-card">
<!-- En-tête de saison -->
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
<button
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
@@ -14,13 +13,13 @@
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
<h2 class="font-display text-[17px] font-bold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">— {{ season.title }}</span>
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">· {{ season.title }}</span>
</h2>
<p class="text-meta text-muted">{{ seasonMeta }}</p>
</div>
<div class="flex items-center gap-2.5 flex-wrap">
<!-- ── Mode demande ──── -->
<!-- ── Mode demande ── -->
<template v-if="requestMode">
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -34,11 +33,11 @@
class="btn-secondary btn-sm"
>
<Clock3 :size="14" :stroke-width="2" />
{{ missingCount === season.episodes.length ? 'Demander la saison' : `Demander ${missingCount > 1 ? `les ${missingCount} manquants` : 'le manquant'}` }}
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
</button>
</template>
<!-- ── Mode téléchargement (admin) ──────── -->
<!-- ── Mode téléchargement (admin) ── -->
<template v-else>
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -80,7 +79,6 @@
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
class="btn-secondary btn-sm"
@@ -91,7 +89,6 @@
{{ seasonBtnLabel }}
</button>
<!-- Packs saison multiples -->
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
<Download :size="14" />
@@ -114,13 +111,12 @@
</div>
</div>
<!-- Désimport de la saison -->
<button
v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Désimporter la saison"
aria-label="Désimporter la saison"
title="Retirer la saison de la médiathèque"
aria-label="Retirer la saison de la médiathèque"
>
<Trash2 :size="15" />
</button>
@@ -128,7 +124,6 @@
</div>
</div>
<!-- Épisodes -->
<div v-if="!collapsed">
<div
v-for="ep in season.episodes"
@@ -136,7 +131,6 @@
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
>
<div class="flex items-center gap-3 sm:gap-3.5">
<!-- Vignette -->
<div
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
@@ -145,7 +139,6 @@
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<!-- Infos épisode -->
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
@@ -157,13 +150,13 @@
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
title="Ce fichier n'est pas une version officielle Fankai"
>Hors Fankai</span>
</div>
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
</div>
<!-- ── État et actions, mode demande ─────────────────── -->
<!-- ── État et actions, mode demande ── -->
<template v-if="requestMode">
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
@@ -177,7 +170,7 @@
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
</button>
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
<p class="tag-label px-3 pt-1.5 pb-1">Choisir le torrent</p>
<p class="tag-label px-3 pt-1.5 pb-1">Choisir la version</p>
<button
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
@@ -193,7 +186,7 @@
</div>
</template>
<!-- ── État et actions, admin ────────────────────────── -->
<!-- ── État et actions, admin ── -->
<template v-else>
<template v-if="ep.organized">
<button
@@ -246,14 +239,13 @@
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Désimporter l'épisode"
aria-label="Désimporter l'épisode"
title="Retirer l'épisode de la médiathèque"
aria-label="Retirer l'épisode de la médiathèque"
>
<Trash2 :size="14" />
</button>
</template>
<!-- Synopsis -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
@@ -266,12 +258,10 @@
</button>
</div>
<!-- Progression de l'épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
<!-- Synopsis -->
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
{{ parseEpChap(ep.plot).cleanPlot }}
</p>
@@ -279,13 +269,12 @@
</div>
</section>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-ep-title" class="card-title">Désimporter l'épisode</h3>
<p class="text-meta text-muted">L'épisode sera retiré de la bibliothèque.</p>
<h3 id="unimport-ep-title" class="card-title">Retirer l'épisode de la médiathèque</h3>
<p class="text-meta text-muted">L'épisode sera retiré de la médiathèque. Le fichier reste sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
@@ -294,20 +283,19 @@
<div class="flex gap-2.5 justify-end">
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimport" class="btn-danger">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
{{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-season-title" class="card-title">Désimporter la saison</h3>
<p class="text-meta text-muted">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
<p class="text-meta text-muted">Les épisodes importés de cette saison seront retirés de la médiathèque. Les fichiers restent sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
@@ -316,7 +304,7 @@
<div class="flex gap-2.5 justify-end">
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSeason" class="btn-danger">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
{{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
@@ -327,6 +315,7 @@
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
import { plural } from '@/utils/format'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
@@ -341,9 +330,9 @@ const props = defineProps<{
downloadingSeason ?: boolean
nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
requestStatus ?: string // statut de la demande en cours de cet user
requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par l'utilisateur pour cette série
requestStatus ?: string // statut de la demande en cours de l'utilisateur
}>()
const emit = defineEmits<{
@@ -362,7 +351,6 @@ const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false
)
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
function isEpisodeRequested(episodeId: number): boolean {
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
}
@@ -378,7 +366,7 @@ const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
// ── Helpers hash / progress ────────────────────────────────────
// ── Hash et progression ──
function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase()
@@ -393,16 +381,14 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
// Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Computed saison ────────────────────────────────────────────
// ── État de la saison ──
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) =>
@@ -414,8 +400,8 @@ const canDownloadSeason = computed(() =>
)
const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajoutée'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
return 'Télécharger la saison'
})
const seasonBtnIcon = computed(() => {
@@ -425,7 +411,7 @@ const seasonBtnIcon = computed(() => {
return h(Download, { size: 14 })
})
// ── Résumés de saison ──────────────────────────────────────────
// ── Résumés de saison ──
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
const downloadableCount = computed(() =>
@@ -433,36 +419,35 @@ const downloadableCount = computed(() =>
)
const downloadableLabel = computed(() => {
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
return downloadableCount.value > 1 ? `les ${downloadableCount.value} manquants` : 'le manquant'
return downloadableCount.value > 1 ? `${downloadableCount.value} épisodes` : "l'épisode"
})
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
const seasonMeta = computed(() => {
const total = props.season.episodes.length
const parts = [`${total} épisode${total > 1 ? 's' : ''}`]
const parts = [plural(total, 'épisode')]
if (props.requestMode) {
const ready = props.season.organized_count ?? 0
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : `${ready} disponible${ready > 1 ? 's' : ''}`)
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : plural(ready, 'disponible'))
return parts.join(' · ')
}
if (availableCount.value > 0) parts.push(`${availableCount.value} disponible${availableCount.value > 1 ? 's' : ''}`)
if (props.season.organized_count > 0) parts.push(`${props.season.organized_count} importé${props.season.organized_count > 1 ? 's' : ''}`)
if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
return parts.join(' · ')
})
// Date · durée · langue · sources · plages d'épisodes et de chapitres, en texte
// Date · durée · langue · versions · plages d'épisodes et de chapitres, en texte
function episodeMeta(ep: any): string {
const { episodes, chapters } = parseEpChap(ep.plot)
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} sources` : ''
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} versions` : ''
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
.filter(Boolean).join(' · ')
}
// ── État épisode ───────────────────────────────────────────────
// ── État de l'épisode ──
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
// Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
@@ -495,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | null {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index)
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
// Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
}
@@ -517,11 +499,9 @@ function epProgress(ep: any): ActiveTorrent | null {
function handleEpBtnClick(ep: any) {
if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
@@ -530,25 +510,22 @@ function handleEpBtnClick(ep: any) {
function handleEpRequestClick(ep: any) {
if (isEpisodeRequested(ep.id)) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs torrents → afficher le picker
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Torrent unique ou aucun → émettre directement
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
// Vert uniquement quand importé dans la bibliothèque
if (ep.organized) return 'done'
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep)
if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle'
}
// ── Rename helpers ─────────────────────────────────────────────
// ── Renommage ──
function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return ''
@@ -556,9 +533,6 @@ function epExpectedName(ep: any): string {
if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else {
// Préférer le formatted_name du torrent qui correspond au fichier importé.
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
// cherche le premier torrent qui a un formatted_name.
const usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
@@ -575,15 +549,10 @@ function epNeedsRename(ep: any): boolean {
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
// ── Épisodes et chapitres cités dans le synopsis ──
// Cache : le synopsis est analysé plusieurs fois par rendu
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
*/
// Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
season: string | null
@@ -637,7 +606,7 @@ function parseEpChap(plot: string | null | undefined) {
return result
}
// ── Détection langue ───────────────────────────────────────────
// ── Langue ──
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [
ep.formatted_name ?? '',
@@ -653,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
return null
}
// ── Labels de groupe ───────────────────────────────────────────
// Si tous les torrent_name sont présents ET distincts → on les utilise.
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
// ── Libellés des versions ──
function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
@@ -666,18 +633,13 @@ function groupLabels(torrents: any[]): string[] {
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
}
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
// Un torrent individuel par épisode (hash unique) n'est PAS un pack → ignoré ici,
// le bouton "Saison" simple suffira pour lancer tous les épisodes.
// Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
const uniquePackOptions = computed(() => {
if (props.season.torrents?.length) return []
// Compter combien d'épisodes chaque hash couvre
const hashCount = new Map<string, number>()
const hashTorrent = new Map<string, any>()
for (const ep of props.season.episodes) {
@@ -690,7 +652,6 @@ const uniquePackOptions = computed(() => {
}
}
// Garder uniquement les hash qui couvrent ≥ 2 épisodes (= vrais packs / intégrales)
const packs = [...hashCount.entries()]
.filter(([, count]) => count > 1)
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
+4 -4
View File
@@ -4,7 +4,7 @@
<header class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
<div class="flex items-baseline gap-3.5">
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
<span class="text-[13px] text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
<span class="text-[13px] text-muted">{{ plural(count, 'série') }}</span>
</div>
<div class="flex items-center gap-2.5 w-full md:w-auto">
@@ -16,7 +16,7 @@
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
@keydown.esc="emit('update:search', '')"
type="text"
:placeholder="selectable ? 'Rechercher dans la médiathèque' : 'Rechercher une série'"
placeholder="Rechercher une série"
aria-label="Rechercher une série"
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
/>
@@ -39,7 +39,6 @@
</div>
</header>
<!-- Filtres, tri et taille -->
<div ref="rowRef" class="relative">
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
@@ -81,7 +80,7 @@
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
:aria-pressed="posterSize === size"
:title="`Affiches ${size}`"
:title="`Taille des affiches : ${size}`"
>
{{ size }}
</button>
@@ -120,6 +119,7 @@
import { ref, computed } from 'vue'
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { plural } from '@/utils/format'
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
@@ -9,8 +9,8 @@
<div class="flex flex-col gap-[7px]">
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Tableau de bord → Clés API'" />
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord → Clés API → Créer une clé.</p>
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Collez la clé API'" />
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
</div>
</div>
@@ -27,6 +27,7 @@
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
<span v-if="saveError" class="text-meta text-err">{{ saveError }}</span>
</div>
</div>
</template>
@@ -46,6 +47,7 @@ const jellyfinAdminToken = ref('')
const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const saveError = ref<string | null>(null)
const testing = ref(false)
const testResult = ref<JellyfinTestResult | null>(null)
@@ -64,15 +66,19 @@ onMounted(async () => {
})
async function save() {
saving.value = true
saved.value = false
saving.value = true
saved.value = false
saveError.value = null
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (res.ok) {
}).catch(() => null)
if (!res?.ok) {
const d = await res?.json().catch(() => ({}))
saveError.value = d?.error ?? 'Impossible d\'enregistrer les réglages Jellyfin.'
} else {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = ''
+26 -28
View File
@@ -3,7 +3,6 @@
<div class="modal-backdrop" @click.self="emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
<!-- Header -->
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div>
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
@@ -12,13 +11,13 @@
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }}
'' }}
</p>
</div>
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Étape 1 : Auth -->
<!-- Étape 1 : connexion -->
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button
@@ -30,7 +29,7 @@
</div>
<div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
<p class="text-meta text-muted">Google, Apple, ou compte Plex</p>
<p class="text-meta text-muted">Google, Apple ou compte Plex</p>
</div>
</button>
@@ -43,22 +42,20 @@
</div>
<div>
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
<p class="text-meta text-muted">Connexion directe avec vos identifiants</p>
<p class="text-meta text-muted">Sans passer par la page de connexion Plex</p>
</div>
</button>
</div>
<!-- OAuth en attente -->
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
<div>
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
<p class="text-meta text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
<p class="text-meta text-muted mt-1">Connectez-vous dans l'onglet Plex qui vient de s'ouvrir. Rien ne s'est ouvert ? Autorisez les fenêtres pop-up.</p>
</div>
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
</div>
<!-- Formulaire credentials -->
<template v-else-if="plexAuthMethod === 'credentials'">
<div>
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
@@ -69,7 +66,7 @@
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div>
<div v-if="plexNeeds2FA">
<label class="field-label mb-1 block">Code 2FA</label>
<label class="field-label mb-1 block">Code de validation (6 chiffres)</label>
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
@@ -85,9 +82,9 @@
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div>
<!-- Étape 2 : Choix serveur -->
<!-- Étape 2 : choix du serveur -->
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
<p class="text-meta text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
<p class="text-meta text-muted">{{ plural(plexServers.length, 'serveur trouvé', 'serveurs trouvés') }}</p>
<div
v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)"
@@ -96,7 +93,7 @@
>
<div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
<p class="text-meta text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
<p class="text-meta text-muted">{{ server.owned ? 'Votre serveur' : 'Partagé avec vous' }}</p>
</div>
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
</div>
@@ -117,7 +114,7 @@
</div>
<div>
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
<p class="text-xs text-muted mb-1.5">Le dossier de la médiathèque tel que Plex le voit (il peut différer du chemin FanKarr).</p>
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
</div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
@@ -147,9 +144,9 @@
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
<li>Rafraîchissez les métadonnées</li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
<li>Dans le menu ⋯ de votre bibliothèque, <span class="text-primary">Manage Library › Edit › Advanced › Agent</span> : sélectionnez l'agent Fankai</li>
<li>Actualisez les métadonnées</li>
</ol>
</div>
@@ -164,6 +161,7 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { Check, Clock3, User, X } from 'lucide-vue-next'
import { plural } from '@/utils/format'
const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>()
@@ -190,21 +188,21 @@ const plexForm = ref({
libraryPath: props.mediaPath,
})
// ── OAuth ─────────────────────────────────────────────────────
// ── Connexion via Plex ──
async function startOAuth() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
if (!res.ok) { plexError.value = data.error ?? "Impossible d'ouvrir la connexion Plex."; return }
plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank')
let attempts = 0
plexOAuthTimer = setInterval(async () => {
attempts++
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai dépassé. Réessayez.'; return }
try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json()
@@ -218,7 +216,7 @@ async function startOAuth() {
} catch {}
}, 2000)
} catch {
plexError.value = "Impossible de démarrer l'authentification"
plexError.value = "Impossible d'ouvrir la connexion Plex."
} finally {
plexLoading.value = false
}
@@ -230,7 +228,7 @@ function cancelOAuth() {
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
}
// ── Credentials ───────────────────────────────────────────────
// ── Identifiants ──
async function plexConnect() {
plexError.value = ''
plexLoading.value = true
@@ -241,8 +239,8 @@ async function plexConnect() {
})
const data = await res.json()
if (!res.ok) {
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
else plexError.value = data.error ?? 'Erreur de connexion'
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
return
}
plexToken.value = data.token
@@ -250,7 +248,7 @@ async function plexConnect() {
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
@@ -266,11 +264,11 @@ async function plexConnectServer() {
const conns = plexSelectedServer.value.connections ?? []
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
plexServerUrl.value = conn?.uri ?? ''
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
if (!plexServerUrl.value) throw new Error('Ce serveur Plex ne propose aucune adresse de connexion.')
plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library'
} catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur'
plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
} finally {
plexLoading.value = false
}
@@ -288,9 +286,9 @@ async function plexSetup() {
plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done'
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
if (!data.ok) plexError.value = data.error ?? 'Impossible de configurer Plex.'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Accent">
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Couleur de l'interface">
<button
v-for="theme in themes"
:key="theme.id"
+8 -10
View File
@@ -15,7 +15,6 @@
</div>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<SettingsToggle
@@ -39,7 +38,6 @@
</div>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@@ -92,7 +90,7 @@
<button @click="test" :disabled="testing || !type" class="btn-secondary">
<Loader v-if="testing" :size="14" class="animate-spin" />
<Check v-else-if="tested" :size="14" class="text-ok" />
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester' }}
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester la connexion' }}
</button>
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
</div>
@@ -122,10 +120,10 @@ const { add: toast } = useToast()
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
savePath : 'Facultatif. Dossier où le client enregistre les torrents envoyés par FanKarr (ex. /downloads/fankai). Vide, le client utilise son dossier par défaut.',
remotePath : "Si le client tourne sur une autre machine ou dans un autre conteneur : son dossier de téléchargement, tel qu'il le voit (ex. /downloads).",
localPath : 'Le même dossier, tel que FanKarr le voit (ex. /mnt/nas/downloads).',
ignoreCertificateErrors: "Accepte les certificats auto-signés ou invalides en HTTPS. À n'utiliser que sur un réseau de confiance.",
}
const name = ref(props.client?.name ?? '')
@@ -177,7 +175,7 @@ async function test() {
})
const { ok, message } = await res.json()
tested.value = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
toast(ok ? 'Connexion réussie' : (message ?? 'Impossible de se connecter au client torrent'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -199,11 +197,11 @@ async function save() {
}),
})
if (res.ok) {
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
toast(isEdit ? 'Client modifié' : 'Client enregistré', 'success')
emit('saved', await res.json(), isEdit)
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
toast(error ?? "Impossible d'enregistrer le client", 'error')
}
} finally {
saving.value = false
+9 -8
View File
@@ -15,7 +15,7 @@
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Catalogue non téléchargé' : `${status.count} séries disponibles` }}
{{ status.empty ? 'Catalogue non synchronisé' : plural(status.count, 'série disponible', 'séries disponibles') }}
</p>
<p class="text-meta text-muted mt-0.5">
{{ status.empty
@@ -24,7 +24,7 @@
</p>
</div>
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0">
{{ updating ? 'Téléchargement…' : status.empty ? 'Télécharger' : 'Mettre à jour' }}
{{ updating ? 'Synchronisation…' : status.empty ? 'Synchroniser le catalogue' : 'Synchroniser' }}
</button>
</section>
@@ -35,8 +35,8 @@
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
<p class="text-meta text-muted mt-0.5">
<template v-if="scanResult">{{ scanResult.found }} fichiers analysés · {{ scanResult.added }} référencés.</template>
<template v-else>Analysez la médiathèque pour référencer les fichiers présents, sans les déplacer.</template>
<template v-if="scanResult">{{ plural(scanResult.found, 'fichier trouvé', 'fichiers trouvés') }}, {{ plural(scanResult.added, 'ajouté', 'ajoutés') }} à la médiathèque.</template>
<template v-else>Analysez la médiathèque pour reconnaître les épisodes déjà présents. Aucun fichier n'est déplacé.</template>
</p>
</div>
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
@@ -45,7 +45,7 @@
</section>
<p v-if="warnEmpty" class="text-body text-accent" role="alert">
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
Le catalogue est encore vide. Synchronisez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
</p>
</template>
@@ -56,6 +56,7 @@
import { ref, onMounted } from 'vue'
import { Library, ScanSearch } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { plural } from '@/utils/format'
import type { SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
@@ -77,9 +78,9 @@ async function update() {
if (res.ok) {
status.value = { exists: true, count: data.count, empty: data.count === 0 }
warnEmpty.value = false
toast(`${data.count} séries chargées ✓`, 'success')
toast(`Catalogue synchronisé : ${plural(data.count, 'série')}`, 'success')
} else {
toast(data.error ?? 'Erreur lors de la mise à jour', 'error')
toast(data.error ?? 'Impossible de synchroniser le catalogue', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -96,7 +97,7 @@ async function scan() {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
} else {
toast("Erreur lors de l'analyse", 'error')
toast("Impossible d'analyser la médiathèque", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+2 -5
View File
@@ -6,7 +6,6 @@
</div>
<template v-else>
<!-- Clients existants -->
<div v-if="clients.length > 0" class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid" class="card flex items-center gap-3">
<span
@@ -25,7 +24,6 @@
</div>
</div>
<!-- Formulaire -->
<div v-if="formOpen" class="card flex flex-col gap-4">
<p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
<TorrentClientForm
@@ -41,7 +39,6 @@
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
</button>
<!-- Rappel chemins -->
<div class="flex items-start gap-3 px-4 py-3 rounded-field border border-border bg-card/50">
<Info :size="15" class="text-muted mt-0.5 shrink-0" />
<p class="text-meta text-muted leading-relaxed">
@@ -58,8 +55,8 @@
<div class="flex flex-col gap-0.5">
<p class="text-body text-accent font-medium">Aucun client configuré</p>
<p class="text-meta text-muted">
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même,
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement.
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour passer cette étape.
Vous pourrez en ajouter un plus tard dans Paramètres › Clients torrent.
</p>
</div>
</div>
+8 -8
View File
@@ -5,23 +5,23 @@
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé, puis vérifie toutes les 5 minutes. Sinon, l'import se lance à la main depuis Activité."
description="Importe les téléchargements terminés (vérification toutes les 5 minutes). Désactivé, vous lancez l'import depuis la page Activité."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » au lieu de « Saison »"
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »."
label="Dossiers de saison en anglais"
description="« Season 01 » au lieu de « Saison 1 »."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier disparaît"
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant."
label="Retirer les épisodes supprimés du disque"
description="À la prochaine analyse, un épisode dont le fichier a disparu est retiré de la médiathèque."
/>
<SettingsToggle
v-if="settings.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client une fois le fichier déplacé."
description="Le torrent ne peut plus être partagé une fois le fichier déplacé : autant le retirer du client."
/>
</div>
@@ -29,7 +29,7 @@
<SettingsToggle
v-model="form.nfoSupport"
label="Fichiers NFO et images"
description="Télécharge NFO et visuels à côté de chaque épisode lors de l'import."
description="Enregistre à côté de chaque épisode un fichier .nfo (titre, résumé…) et ses images."
/>
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-field border border-border bg-main/50 text-meta text-muted leading-relaxed">
<Info :size="13" class="shrink-0 mt-0.5" />
@@ -67,7 +67,7 @@ const error = ref<string | null>(null)
async function submit(): Promise<boolean> {
error.value = null
if (await postSettings(form)) return true
error.value = "Erreur lors de l'enregistrement."
error.value = "Impossible d'enregistrer les réglages d'import."
return false
}
@@ -1,7 +1,6 @@
<template>
<div class="flex flex-col gap-4">
<!-- Jellyfin -->
<section class="card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -13,7 +12,7 @@
<span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
</p>
<p class="text-meta text-muted mt-0.5 leading-relaxed">
Permet à vos utilisateurs Jellyfin de se connecter à FanKarr et de faire des demandes depuis le plugin FanKarr Search.
Vos utilisateurs Jellyfin se connectent à FanKarr et font leurs demandes depuis le plugin FanKarr Search.
</p>
</div>
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
@@ -31,7 +30,7 @@
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="syncResult" class="text-meta text-ok">
{{ syncResult.created }} créé{{ syncResult.created > 1 ? 's' : '' }} · {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
{{ plural(syncResult.created, 'créé', 'créés') }} · {{ plural(syncResult.skipped, 'déjà existant', 'déjà existants') }}
</span>
<span v-if="syncError" class="text-meta text-err">{{ syncError }}</span>
</div>
@@ -40,7 +39,6 @@
</template>
</section>
<!-- Plex -->
<section class="card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -53,7 +51,7 @@
</p>
<p class="text-meta text-muted mt-0.5 leading-relaxed">
Crée une bibliothèque Plex sur votre médiathèque, reliée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43 ou plus récent pour la configuration automatique.
Nécessite Plex Media Server 1.43 ou plus récent pour la configuration automatique.
</p>
</div>
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary btn-sm shrink-0">
@@ -63,7 +61,7 @@
<p v-if="!settings.mediaPath" class="text-meta text-accent">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
</section>
<p class="text-meta text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
<p class="text-meta text-muted">Pas encore de Jellyfin ni de Plex ? Passez cette étape : vous pourrez les configurer plus tard dans les paramètres.</p>
</div>
@@ -76,6 +74,7 @@ import { Server, Tv } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
import { setupContextKey, type SetupSettings } from './setup'
import { plural } from '@/utils/format'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
@@ -101,7 +100,7 @@ async function syncUsers() {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin'
} catch {
syncError.value = 'Impossible de contacter le serveur'
} finally {
+8 -10
View File
@@ -18,7 +18,7 @@
class="field"
spellcheck="false"
/>
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Parcourir — ${field.label}`">
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Choisir le dossier : ${field.label}`">
<FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
</button>
</div>
@@ -34,15 +34,14 @@
</div>
<p v-if="result?.relation === 'same'" class="text-meta text-err flex items-start gap-1.5">
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque doit être distincte du dossier de téléchargements.
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque et le dossier des téléchargements doivent être différents.
</p>
<p v-else-if="result?.relation === 'nested'" class="text-meta text-accent flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" />
Un dossier est à l'intérieur de l'autre : ça fonctionne, mais le scan de la médiathèque verra aussi les téléchargements.
L'un des dossiers est dans l'autre. C'est possible, mais l'analyse de la médiathèque trouvera aussi les téléchargements.
</p>
</div>
<!-- Mode d'import -->
<div class="card flex flex-col gap-3">
<label class="field-label">Mode d'import</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@@ -62,7 +61,6 @@
</button>
</div>
<!-- Résultat du test hardlink -->
<div v-if="result && form.organizeMode === 'hardlink'" class="text-meta">
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-ok flex items-center gap-1.5">
<Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
@@ -71,7 +69,7 @@
<p class="text-accent flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
</p>
<p class="text-muted">Sans hardlink, FanKarr copiera les fichiers (espace disque doublé).</p>
<p class="text-muted">Les hardlinks ne fonctionnent pas entre ces dossiers : en mode Copier, chaque fichier prendra deux fois la place.</p>
<button @click="form.organizeMode = 'copy'" class="btn-secondary btn-sm self-start">Passer en mode Copier</button>
</div>
<p v-else class="text-muted">{{ result.hardlink.message }}</p>
@@ -104,7 +102,7 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
{
key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete',
help: 'Là où votre client torrent dépose les fichiers terminés. Sert aussi à tester les hardlinks.',
help: 'Dossier où votre client torrent place les fichiers terminés.',
},
{
key: 'mediaPath', label: 'Médiathèque Fankai', required: true,
@@ -114,9 +112,9 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
]
const modes = [
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Aucune copie : le fichier reste en partage dans le client torrent.' },
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Le fichier apparaît dans la médiathèque sans être copié ; le client continue de le partager.' },
{ id: 'copy' as const, label: 'Copier', text: 'Duplique le fichier. Fonctionne partout, prend deux fois la place.' },
{ id: 'move' as const, label: 'Déplacer', text: 'Retire le fichier du client : le partage s\'arrête.' },
{ id: 'move' as const, label: 'Déplacer', text: "Retire le fichier du client : le partage s'arrête." },
]
const form = reactive({
@@ -175,7 +173,7 @@ async function submit(): Promise<boolean> {
if (!res.ok) { error.value = 'Corrigez les dossiers signalés avant de continuer.'; return false }
if (!(await postSettings({ ...form, mediaPath: res.mediaPath.resolved, completePath: res.completePath?.resolved ?? '' }))) {
error.value = "Erreur lors de l'enregistrement."
error.value = "Impossible d'enregistrer les dossiers."
return false
}
return true
+9 -8
View File
@@ -21,14 +21,14 @@
<div class="flex flex-col sm:flex-row gap-2">
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary">
<Compass :size="15" />
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
{{ context.relaunch ? 'Revoir la visite guidée' : 'Terminer avec la visite guidée' }}
</button>
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
</button>
</div>
<p v-if="!context.relaunch" class="text-meta text-muted -mt-3">
La visite guidée présente chaque écran en une minute. Vous pourrez la relancer depuis le bouton « ? » de la barre latérale.
La visite guidée présente les écrans principaux en une minute. Vous pourrez la relancer avec le bouton « ? ».
</p>
</div>
@@ -38,6 +38,7 @@
import { ref, reactive, inject, onMounted } from 'vue'
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
import { plural } from '@/utils/format'
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
interface Row { label: string; value: string; state: RowState; step: SetupStepId }
@@ -55,7 +56,7 @@ const stateClass: Record<RowState, string> = {
neutral: 'bg-hover text-muted',
}
const modeLabel = { hardlink: 'Hardlink', copy: 'Copie', move: 'Déplacement' }
const modeLabel = { hardlink: 'Hardlink', copy: 'Copier', move: 'Déplacer' }
const row = (label: string, step: SetupStepId, value = '…', state: RowState = 'loading'): Row => ({ label, step, value, state })
@@ -64,11 +65,11 @@ const rows = reactive({
mode : row("Mode d'import", 'paths'),
client : row('Client torrent', 'client'),
import : row('Import automatique', 'import',
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis Activité',
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis la page Activité',
props.settings.autoImport ? 'ok' : 'neutral'),
nfo : row('NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
nfo : row('Fichiers NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
jellyfin: row('Jellyfin', 'media-server'),
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant Plex lancé' : 'Non configuré', 'neutral'),
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant ouvert, non vérifié' : 'Non configuré', 'neutral'),
catalog : row('Catalogue Fankai', 'catalog'),
})
@@ -90,7 +91,7 @@ async function loadPaths() {
set(rows.paths, 'Médiathèque non configurée', 'error')
} else {
set(rows.paths,
`${s.mediaPath}${s.completePath ? ` ← ${s.completePath}` : ' · téléchargements non renseignés'}`,
`Médiathèque : ${s.mediaPath} · ${s.completePath ? `Téléchargements : ${s.completePath}` : 'dossier des téléchargements non renseigné'}`,
!res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
}
@@ -139,7 +140,7 @@ async function loadCatalog() {
try {
const { count, empty } = await getJson('/api/torrents/status')
if (empty) set(rows.catalog, 'Catalogue vide', 'warn')
else set(rows.catalog, `${count} séries disponibles`, 'ok')
else set(rows.catalog, plural(count, 'série disponible', 'séries disponibles'), 'ok')
} catch {
set(rows.catalog, 'Impossible de vérifier', 'warn')
}
+5 -8
View File
@@ -1,7 +1,6 @@
<template>
<div class="flex flex-col gap-6">
<!-- Fonctionnement -->
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<li v-for="(item, i) in flow" :key="item.title" class="card flex gap-3">
<span class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -14,7 +13,6 @@
</li>
</ol>
<!-- Ce qu'on va configurer -->
<div class="card flex flex-col gap-3">
<p class="field-label">Au programme</p>
<ul class="flex flex-col gap-2">
@@ -22,14 +20,13 @@
<Check :size="14" class="text-accent mt-0.5 shrink-0" />
<span class="text-secondary">
{{ item.label }}
<span v-if="item.optional" class="text-muted text-meta">(optionnel)</span>
<span v-if="item.optional" class="text-muted text-meta">(facultatif)</span>
</span>
</li>
</ul>
<p class="text-meta text-muted">Comptez environ 5 minutes.</p>
</div>
<!-- Docker -->
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-field border border-accent/30 bg-accent-muted">
<HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
<div class="flex flex-col gap-1">
@@ -54,16 +51,16 @@ defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>
const flow = [
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' },
{ icon: Download, title: 'Téléchargement', text: 'Un clic envoie le torrent à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' },
{ icon: Download, title: 'Téléchargement', text: 'Le torrent est envoyé à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois le téléchargement terminé, le fichier est renommé et rangé par série et par saison.' },
{ icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
]
const checklist = [
{ label: 'Dossier de téléchargements et médiathèque' },
{ label: 'Dossier des téléchargements et médiathèque' },
{ label: 'Client torrent' },
{ label: 'Import automatique et nommage' },
{ label: 'Jellyfin ou Plex', optional: true },
{ label: 'Téléchargement du catalogue' },
{ label: 'Synchronisation du catalogue' },
]
</script>
+1 -1
View File
@@ -171,7 +171,7 @@ async function showStep() {
if (el) {
const r = el.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) {
el.scrollIntoView({ block: 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
el.scrollIntoView({ block: r.height > window.innerHeight ? 'start' : 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
}
}
await nextTick()
+1 -2
View File
@@ -1,10 +1,9 @@
/** Copie du texte dans le presse-papiers avec fallback pour HTTP non-localhost. */
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
return
}
// Fallback : execCommand (HTTP non sécurisé)
// Hors HTTPS, navigator.clipboard est absent : repli sur execCommand
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
+3 -11
View File
@@ -11,31 +11,23 @@ interface Toast {
const toasts = ref<Toast[]>([])
let nextId = 0
const DURATION = 3500
const MAX_VISIBLE = 4
function add(message: string, type: Toast['type'] = 'info') {
// Cherche un toast récent du même type à grouper
const existing = toasts.value.find(t => t.type === type)
const existing = toasts.value.find(t => t.type === type && t.message === message)
if (existing) {
// Grouper — incrémenter le count et mettre à jour le message
clearTimeout(existing.timer)
existing.count++
existing.message = buildGroupMessage(type, existing.count)
existing.timer = setTimeout(() => remove(existing.id), DURATION)
return
}
// Nouveau toast
const id = nextId++
const timer = setTimeout(() => remove(id), DURATION)
toasts.value.push({ id, message, type, count: 1, timer })
}
function buildGroupMessage(type: Toast['type'], count: number): string {
if (type === 'success') return `${count} opérations réussies`
if (type === 'error') return `${count} erreurs`
return `${count} notifications`
if (toasts.value.length > MAX_VISIBLE) remove(toasts.value[0]!.id)
}
function remove(id: number) {
-3
View File
@@ -29,7 +29,6 @@ function isNewer(latest: string, current: string): boolean {
async function checkForUpdates() {
try {
// Version locale
const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) {
const { version } = await vRes.json()
@@ -38,7 +37,6 @@ async function checkForUpdates() {
if (currentVersion.value === 'dev') return
// Dernière release GitHub
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' },
})
@@ -57,7 +55,6 @@ export function useUpdateCheck() {
onMounted(() => {
if (users++ > 0) return
checkForUpdates()
// Revérifier toutes les 6h
timer = setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
})
+1 -4
View File
@@ -54,7 +54,7 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
}
})
// Première visite : une seule fois par session, après l'assistant pour un admin
// Visite guidée auto : une fois par session, après l'assistant pour un admin
let tourAutoStarted = false
watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
if (tourAutoStarted || tour.active) return
@@ -64,7 +64,6 @@ watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
}
}, { immediate: true })
// Compteurs de pour la barre latérale
let pendingTimer: ReturnType<typeof setInterval> | null = null
let downloadsTimer: ReturnType<typeof setInterval> | null = null
@@ -84,7 +83,6 @@ onUnmounted(() => {
watch(() => route.path, () => reqStore.refreshPending())
// Mobile
const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings'))
const navItems = computed<NavItem[]>(() => {
@@ -120,6 +118,5 @@ const navItems = computed<NavItem[]>(() => {
return items
})
// Mobile 2
const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings')))
</script>
+5 -11
View File
@@ -12,7 +12,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// ─── Pages publiques hors layout ─────────────────────────
// ── Pages hors layout (connexion, invitation, assistant) ──
{
path: '/auth',
component: () => import('@/views/AuthView.vue'),
@@ -30,7 +30,7 @@ const router = createRouter({
meta: { adminOnly: true },
},
// ─── App principale avec layout sidebar ──────────────────
// ── Pages avec barre latérale ──
{
path: '/',
component: AppLayout,
@@ -38,7 +38,6 @@ const router = createRouter({
{ path: '', redirect: '/dashboard' },
{ path: 'downloads', redirect: '/activity' },
// Accueil
{
path: 'dashboard',
name: 'dashboard',
@@ -64,7 +63,6 @@ const router = createRouter({
component: () => import('@/views/DownloadsView.vue'),
},
// Paramètres
{
path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'),
@@ -98,7 +96,7 @@ const router = createRouter({
{
path: 'logs',
name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
component: () => import('@/views/settings/LogsView.vue'),
},
{
path: 'advanced',
@@ -120,7 +118,6 @@ const router = createRouter({
],
},
// Demandes
{
path: 'requests',
name: 'requests',
@@ -133,25 +130,22 @@ const router = createRouter({
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.name === 'setup') return { top: 0 }
// Retour depuis le détail vers la liste — ne pas scroller en haut
// Retour à la liste : garder la position de défilement
if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 }
},
})
// ─── Guard auth ───────────────────────────────────────────────
// ── Contrôle d'accès ──
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (auth.loading) await auth.checkStatus()
// Redirige vers /auth si non connecté
if (!to.meta.public && !auth.loggedIn) return '/auth'
// Redirige vers l'accueil si déjà connecté et tente d'accéder à /auth
if (to.path === '/auth' && auth.loggedIn) return '/dashboard'
// Redirige les non-admins hors des pages admin
if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard'
// Premier lancement : l'assistant de configuration est obligatoire
+2 -2
View File
@@ -43,7 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
await checkStatus()
return null
}
return data.error
return data.error ?? 'Impossible de se connecter.'
}
async function setupAccount(u: string, password: string): Promise<string | null> {
@@ -63,7 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
tourSeen.value = false
return null
}
return data.error
return data.error ?? 'Impossible de créer le compte.'
}
async function logout() {
+2 -2
View File
@@ -18,8 +18,8 @@ export const useCatalogStore = defineStore('catalog', () => {
error.value = null
try {
productions.value = await fetchProductions()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de charger le catalogue.'
} finally {
loading.value = false
}
+9 -10
View File
@@ -88,18 +88,17 @@ export const useSeriesStore = defineStore('series', () => {
error.value = null
try {
const res = await fetch('/api/series')
if (!res.ok) throw new Error(`Erreur ${res.status}`)
if (!res.ok) { error.value = 'Impossible de charger les séries.'; return }
const data = await res.json()
series.value = data.series
// Précache des posters en arrière-plan
for (const s of data.series as Serie[]) {
if (s.poster_image && !_imageCache.has(s.poster_image)) {
_imageCache.add(s.poster_image)
new Image().src = s.poster_image
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de contacter le serveur.'
} finally {
loadingSeries.value = false
}
@@ -111,10 +110,10 @@ export const useSeriesStore = defineStore('series', () => {
currentSerie.value = null
try {
const res = await fetch(`/api/series/${id}`)
if (!res.ok) throw new Error(`Erreur ${res.status}`)
if (!res.ok) { error.value = 'Impossible de charger la série.'; return }
currentSerie.value = await res.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de contacter le serveur.'
} finally {
loadingDetail.value = false
}
@@ -129,9 +128,9 @@ export const useSeriesStore = defineStore('series', () => {
})
const data = await res.json()
if (res.ok) return { success: true }
return { success: false, error: data.error }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }
return { success: false, error: data.error ?? "Impossible d'envoyer le torrent au client" }
} catch {
return { success: false, error: 'Impossible de contacter le serveur' }
}
}
+20 -20
View File
@@ -12,58 +12,58 @@ export interface TourStep {
const welcome: TourStep = {
id: 'welcome',
title: 'Bienvenue dans FanKarr',
body: 'Une visite rapide des écrans principaux. Utilisez les flèches ← → pour naviguer et Échap pour quitter.',
body: 'Une visite rapide des écrans principaux. Au clavier : touches fléchées pour avancer ou revenir, Échap pour quitter.',
}
const help = (isAdmin: boolean): TourStep => ({
id: 'help', target: ['help'], placement: 'right',
title: 'Besoin d\'aide ?',
title: "Besoin d'aide ?",
body: isAdmin
? 'Ce bouton relance la visite ou l\'assistant de configuration à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.'
: 'Ce bouton relance la visite à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.',
? "Ce bouton relance la visite ou l'assistant de configuration. Sur mobile, ces options sont dans le menu de votre avatar, en haut de l'Accueil et des Paramètres."
: "Ce bouton relance la visite. Sur mobile, elle se trouve dans le menu de votre avatar, en haut de l'Accueil et des Paramètres.",
})
const adminSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil',
body: 'Le dernier import en grand, les séries ajoutées récemment, et les chiffres du catalogue : séries dans la médiathèque et séries surveillées.',
body: 'Le dernier import, les séries ajoutées récemment et les chiffres clés : séries au catalogue, dans la médiathèque et surveillées.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'À traiter',
body: 'Demandes en attente, imports en erreur et dossiers à renommer : chaque ligne porte son action. Les demandes arrivent de FanKarr ou du plugin Jellyfin.',
body: "Demandes en attente, imports en erreur et dossiers à renommer, chacun avec son bouton d'action. Les demandes viennent de FanKarr ou du plugin Jellyfin.",
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Tout le catalogue Fankai. L\'état est écrit sur l\'affiche : importée, partielle, en téléchargement, erreur, dossier à renommer ou sans torrent. Cliquez pour ouvrir la fiche.',
body: "Tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement…). Cliquez dessus pour ouvrir sa fiche.",
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Chaque filtre affiche son nombre de séries ; « Plus » garde les filtres secondaires. Triez, et changez la taille des affiches.',
body: 'Chaque filtre indique son nombre de séries, les filtres secondaires sont sous « Plus ». Vous pouvez aussi trier et changer la taille des affiches.',
},
{
id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
title: 'Actions groupées',
body: 'Sélectionnez plusieurs séries pour les renommer ou les surveiller. Une série surveillée télécharge seule ses nouveaux épisodes.',
body: 'Sélectionnez plusieurs séries pour renommer leurs fichiers ou les surveiller. FanKarr télécharge automatiquement les nouveaux épisodes des séries surveillées.',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
title: 'Demandes',
body: '« Approuver et télécharger » lance le téléchargement ; une demande sans torrent est signalée avant d\'être approuvée. Les demandes avec torrent s\'approuvent aussi en une fois.',
body: 'Approuvez ou refusez les demandes des invités. « Approuver et télécharger » envoie aussi le torrent au client ; les demandes sans torrent sont signalées. Le bouton du haut approuve toutes celles qui ont un torrent.',
},
{
id: 'activity', route: '/activity', target: ['activity-header'], placement: 'bottom',
title: 'Activité',
body: 'Une ligne par torrent, du téléchargement à l\'import. Les onglets séparent ce qui est en cours, à importer ou en erreur ; une erreur d\'import s\'affiche dans sa ligne, avec de quoi relancer.',
body: "Un torrent par ligne, du téléchargement à l'import. Les onglets séparent les torrents en cours, à importer et en erreur. En cas d'erreur d'import, un bouton « Réessayer » apparaît sur la ligne.",
},
{
id: 'settings', route: '/settings/download-client', target: ['settings-menu', 'nav-settings'], placement: 'right',
title: 'Paramètres',
body: 'Les réglages sont rangés en quatre groupes : Compte (profil, utilisateurs), Médias (dossiers, séries, catalogue), Services (clients torrent, Jellyfin) et Système (journaux, avancé).',
body: 'Quatre groupes de réglages : Compte, Médias, Services (clients torrent, Jellyfin) et Système.',
},
help(true),
]
@@ -71,24 +71,24 @@ const adminSteps: TourStep[] = [
const userSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil',
body: 'Les nouveautés de la médiathèque et le nombre de séries prêtes à regarder.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'Mes demandes',
body: 'L\'état de vos dernières demandes. Juste en dessous, « Pas encore là » propose des séries à demander en un clic.',
title: 'Vos dernières demandes',
body: "Leur état en un coup d'œil. Juste en dessous, « Pas encore là » suggère des séries que vous pouvez demander.",
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez la fiche pour demander la série, une saison ou un épisode.',
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez leur fiche pour demander la série, une saison ou un épisode.',
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Retrouvez une série et filtrez : prêtes à regarder, bientôt là, pas encore là ou vos demandes.',
body: 'Cherchez une série ou filtrez : « Prêtes à regarder », « Bientôt là », « Pas encore là » ou « Mes demandes ».',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
@@ -97,8 +97,8 @@ const userSteps: TourStep[] = [
},
{
id: 'profile-token', route: '/settings/profile', target: ['profile-token'], placement: 'top',
title: 'Jeton d’API',
body: 'Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte pour faire vos demandes depuis Jellyfin. Le choix de l’accent de couleur se fait juste à côté.',
title: "Jeton d'API",
body: "Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte, pour faire vos demandes depuis Jellyfin. Sur cette page, vous pouvez aussi changer la couleur de l'interface.",
},
help(false),
]
-1
View File
@@ -14,7 +14,6 @@ export function splitTitle(title: string): { name: string; suffix: string } {
return m ? { name: m[1]!, suffix: m[2]! } : { name: title, suffix: '' }
}
// Nouveauté : si importe il y a moins de 7 jours
export function isNew(lastImportedAt: string | null | undefined): boolean {
return !!lastImportedAt && Date.now() - new Date(lastImportedAt).getTime() < 7 * 86_400_000
}
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more