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) # FanKarr : configuration du binaire Bun
# Copier ce fichier en .env dans le même répertoire que le binaire fankarr # Copier ce fichier en .env dans le même dossier que le binaire fankarr
# ── Authentification ────────────────────────────────────────────────────────── # ── 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 # JWT_SECRET=changez-moi-avec-une-chaine-aleatoire-longue
# Durée de validité du token de connexion # Durée de validité de la session
# Exemples : "1d", "7d", "30d", "1y", "never" ou "0" pour pas d'expiration # Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
# AUTH_TOKEN_EXPIRY=30d # AUTH_TOKEN_EXPIRY=30d
# ── Serveur ─────────────────────────────────────────────────────────────────── # ── Serveur ───────────────────────────────────────────────────────────────────
# Port d'écoute (défaut : 9898) # Port d'écoute (défaut : 9898)
# PORT=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 mkdir -p /config
RUN echo "$BUILD_VERSION" > /app/version.txt RUN echo "$BUILD_VERSION" > /app/version.txt
EXPOSE 3001 EXPOSE 9898
ENV NODE_ENV=production ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+187 -153
View File
@@ -11,7 +11,7 @@
![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square) ![Bun](https://img.shields.io/badge/bun-ready-f9f1e1?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)** **Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai Inspiré de Radarr et Sonarr, pour les éditions Kai et Yabai
![Banner](.github/images/banner.svg) ![Banner](.github/images/banner.svg)
@@ -21,77 +21,91 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
## Aperçu ## 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 --> ![Accueil](.github/images/dashboard.webp)
![Catalogue](.github/images/catalog.png)
> 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 --> ![Médiathèque](.github/images/catalog.webp)
![Série](.github/images/serie.png)
> 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 --> ![Série](.github/images/serie.webp)
![Téléchargements](.github/images/downloads.png)
> 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 ## Fonctionnalités
### 📥 Téléchargement ### Téléchargement
- **Catalogue complet** des séries Fankai avec affiches et état de disponibilité - Tout le catalogue Fankai, avec les affiches et l'état de chaque série
- **Téléchargement en un clic** vers votre client torrent — par épisode, par saison ou en intégrale - Envoi au 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) - Choix de la source quand plusieurs torrents existent (qualité, pack)
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels - Bouton « Tout télécharger » : l'intégrale choisie, complétée par les packs des épisodes qu'elle ne contient pas
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale - Progression par épisode, même pour un fichier contenu dans un pack (si le client torrent l'indique)
- **RSS Sync** — surveillance automatique des nouvelles sorties toutes les 6 heures pour les séries abonnées, téléchargement sans intervention - Surveillance : les nouveaux épisodes des séries surveillées sont envoyés au client (vérification toutes les 6 heures)
### 🗂️ Organisation ### 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 - Import automatique des téléchargements terminés dans la médiathèque (vérification toutes les 5 minutes)
- **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 - Trois modes d'import : Hardlink (recommandé), Copier ou Déplacer. Avec un hardlink, le client torrent continue de partager le fichier.
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent - Analyse de la médiathèque : les épisodes déjà présents sur le disque sont reconnus sans être déplacés
- **Désimport automatique** optionnel quand un fichier est supprimé du disque - En option, les épisodes dont le fichier a disparu sont retirés de la médiathèque
- **Suppression du dossier série** automatique lors d'un désimport complet avec suppression des fichiers - 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 ### Demandes
- **Système de demandes** — les utilisateurs peuvent demander des séries, saisons ou épisodes spécifiques depuis l'interface ou le plugin Jellyfin - Les invités demandent une série, une saison ou des épisodes, depuis FanKarr ou depuis le plugin Jellyfin
- **Workflow admin** — approbation / refus avec message / marquage disponible depuis la page dédiée - Les administrateurs approuvent, refusent (avec un message) ou marquent une demande comme disponible
- **Auto-téléchargement** configurable par utilisateur — les demandes sont approuvées et lancées automatiquement pour les utilisateurs autorisés - Approbation automatique, pour tous les invités ou certains seulement : la demande est approuvée et le torrent part aussitôt au client
- **Suppression en masse** pour les admins (bouton "Tout supprimer") - Bouton « Tout supprimer » pour vider la liste
- **Nettoyage automatique** — les demandes marquées "disponible" sont supprimées lors du désimport de la série concernée - 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 ### Métadonnées et affichage
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement) - Badges de langue VOSTFR et MULTI sur chaque épisode, détectés automatiquement
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo` - Fichiers NFO et images en option, pour Infuse ou les lecteurs sans agent Fankai
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import - 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 ### Utilisateurs et Jellyfin
- **Multi-utilisateurs** avec rôles (`admin` / `user`) et système d'invitations - Plusieurs comptes, avec deux rôles (administrateur et invité) et des liens d'invitation
- **SSO Jellyfin** — les utilisateurs se connectent avec leur compte Jellyfin, aucun mot de passe FanKarr requis - Synchronisation Jellyfin : un compte FanKarr est créé pour chaque utilisateur Jellyfin, toutes les heures ou à la demande
- **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 - Connexion depuis le plugin Jellyfin avec le compte Jellyfin
- **Token API personnel** — chaque utilisateur dispose d'un token pour accéder à l'API publique - Un jeton d'API personnel par utilisateur pour l'API publique
### ⚙️ Système ### Système
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI - Journaux filtrables par niveau et par source, avec rotation automatique, que vous pouvez vider depuis l'interface
- **Authentification** par mot de passe avec session JWT - Authentification par mot de passe, session JWT
- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid - Clients torrent pris en charge : qBittorrent, Transmission, Deluge, rTorrent, uTorrent, Synology Download Station et Real-Debrid. Vous pouvez en configurer plusieurs.
- **Compatible Docker / Runtipi / Binaire autonome** - 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é) ### 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 ```yaml
services: services:
@@ -109,10 +123,10 @@ services:
environment: environment:
- PUID=1000 # UID de votre utilisateur (id -u) - PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g) - PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone - TZ=Europe/Paris # Fuseau horaire
volumes: volumes:
- ./config:/config # Config, logs, base de données - ./config:/config # Configuration, journaux et données
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks) - /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
ports: ports:
- 9898:9898 - 9898:9898
restart: unless-stopped restart: unless-stopped
@@ -122,86 +136,96 @@ services:
docker compose up -d docker compose up -d
``` ```
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme 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 | | Système | Archive |
| -------------------------------- | -------------------------------- | | ---------------------------------------- | ---------------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` | | Windows x64 | `fankarr-windows-x64.zip` |
| Windows Legacy (anciens CPU) | `fankarr-windows-legacy.zip` | | Windows x64, anciens processeurs | `fankarr-windows-x64-legacy.zip` |
| Linux x64 | `fankarr-linux-x64.zip` | | Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.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/
├── fankarr.exe ← lancer ce fichier ├── fankarr.exe # à lancer (fankarr sous Linux et macOS)
├── organize-worker.js ← worker d'organisation (ne pas déplacer) ├── organize-worker.js # tâche d'import, à garder à côté du binaire
├── .env ← configuration optionnelle (voir Variables d'environnement) ├── public/ # fichiers de l'interface, à garder à côté du binaire
└── public/ ← assets frontend (ne pas déplacer) ├── 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 ```bash
wget https://github.com/Masutayunikon/FanKarr/releases/download/v3.14.6/fankarr-linux-arm64.zip wget https://github.com/masutayunikon/fankarr/releases/latest/download/fankarr-linux-x64.zip
unzip fankarr-linux-arm64.zip -d fankarr unzip fankarr-linux-x64.zip -d fankarr
cd fankarr cd fankarr
################ # Le binaire est extrait dans binaries/ : le placer à côté de public/ et de organize-worker.js
# if the "fankarr" files in inside the binaries directory use this command mv binaries/fankarr .
# mv ./binaries/fankarr .
##################
chmod +x fankarr chmod +x fankarr
./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 ### Variables d'environnement
| Variable | Défaut | Description | | Variable | Défaut | Description |
| ------------------ | ------------- | ------------------------------------------------------------------------------- | | ------------------- | ------------- | ------------------------------------------------------------------------------------ |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | | `PUID` | `1000` | UID propriétaire des fichiers de `/config` (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | | `PGID` | `1000` | GID propriétaire des fichiers de `/config` (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) | | `TZ` | non défini | Fuseau horaire (ex. `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur | | `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent | | `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é du token de connexion (ex: `7d`, `1y`, `never`) | | `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
| `DATA_DIR` | `./config` | Répertoire de stockage des données (binaire uniquement) | | `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
| `GITHUB_RAW_URL` | repo scraper | URL de base du repo scraper si vous hébergez votre propre instance |
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
--- ---
### Premier démarrage ### Premier lancement
1. Ouvrir `http://localhost:9898` 1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
2. Créer un mot de passe à la première connexion 2. Créez le compte administrateur.
3. Aller dans **Paramètres** → configurer votre client torrent 3. Suivez l'assistant de configuration :
4. Renseigner le chemin vers le dossier des **torrents terminés** et celui de votre **médiathèque** - dossiers : téléchargements terminés, médiathèque et mode d'import (Hardlink, recommandé, Copier ou Déplacer) ;
5. Choisir le mode d'organisation : `hardlink` (recommandé), `move` ou `copy` - client torrent ;
6. Optionnel : lancer le **scan** pour référencer les fichiers déjà présents sur le disque - options d'import : import automatique, nommage des dossiers, fichiers NFO ;
7. Retourner au catalogue et télécharger - 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 --> ![Assistant de configuration](.github/images/setup.webp)
![Paramètres](.github/images/settings.png)
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 ## 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ū/ Black Lagoon Henshū/
@@ -218,59 +242,62 @@ Black Lagoon Henshū/
└── Black Lagoon Henshū.S03E08.MULTI.1080p.x264-FANKAI.mkv └── 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 : 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.
- **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 **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 --> ![Journaux](.github/images/logs.webp)
![Logs](.github/images/logs.png)
> 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 ### Synchronisation des utilisateurs
FanKarr peut créer automatiquement un compte pour chaque utilisateur Jellyfin actif. La synchronisation se fait : 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 en arrière-plan (si Jellyfin est configuré) - automatiquement toutes les heures, si Jellyfin est configuré ;
- **Manuellement** depuis **Paramètres → Jellyfin → Synchroniser** - à 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 | | Méthode | Route | Description |
|---------|----------|-------------| |---------|-------|-------------|
| `POST` | `/api/v1/auth/jellyfin` | Échange un token Jellyfin contre un token FanKarr | | `POST` | `/api/v1/auth/jellyfin` | Échange une session Jellyfin contre le jeton FanKarr de l'utilisateur |
| `GET` | `/api/v1/auth/me` | Informations de l'utilisateur connecté | | `GET` | `/api/v1/auth/me` | Compte associé au jeton |
| `GET` | `/api/v1/series/search?q=` | Recherche dans le catalogue (titre, note, année, description, demande en cours) | | `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 (proxy FanKai) | | `GET` | `/api/v1/series/:id` | Détail d'une série : saisons et épisodes |
| `POST` | `/api/v1/requests` | Créer ou mettre à jour une demande | | `POST` | `/api/v1/requests` | Crée une demande ou complète la demande en cours |
| `GET` | `/api/v1/requests` | Demandes de l'utilisateur authentifié | | `GET` | `/api/v1/requests` | Demandes de l'utilisateur du jeton |
### SSO Jellyfin ### Connexion avec Jellyfin
```http ```http
POST /api/v1/auth/jellyfin POST /api/v1/auth/jellyfin
@@ -279,13 +306,13 @@ Content-Type: application/json
{ "jellyfinUserId": "...", "jellyfinToken": "..." } { "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 ### Recherche
```http ```http
GET /api/v1/series/search?q=dragon+ball GET /api/v1/series/search?q=dragon+ball
Authorization: Bearer <token> Authorization: Bearer <jeton>
``` ```
```json ```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 ```http
GET /api/v1/series/42 GET /api/v1/series/42
Authorization: Bearer <token> Authorization: Bearer <jeton>
``` ```
```json ```json
{ {
"id": 42, "id": 42,
"title": "Dragon Ball Z Kai", "title": "Dragon Ball Z Kai",
"original_title": "DRAGON BALL Z KAI",
"image": "https://...",
"seasons": [ "seasons": [
{ {
"season_number": 1, "season_number": 1,
@@ -336,45 +367,48 @@ Authorization: Bearer <token>
```http ```http
POST /api/v1/requests POST /api/v1/requests
Authorization: Bearer <token> Authorization: Bearer <jeton>
Content-Type: application/json Content-Type: application/json
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] } { "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
``` ```
- `seasons` : numéros de saison (`season_number`). Tableau vide = toutes les saisons. - `serieId` et `serieName` sont obligatoires.
- `episodes` : IDs d'épisodes (`episode.id`). Prend le dessus sur `seasons` si renseigné. - `seasons` : numéros de saison (`season_number`). Sans saison ni épisode, la demande porte sur toute la série.
- Si une demande active existe déjà, les saisons/épisodes sont **fusionnés**. - `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 ## 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** 1. Dans Runtipi, ouvrez **Paramètres › App Stores**.
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore` 2. Ajoutez l'adresse `https://github.com/Masutayunikon/runtipi-appstore`.
3. FanKarr apparaît dans le catalogue → **Installer** 3. FanKarr apparaît dans la liste des applications : cliquez sur **Installer**.
--- ---
## Stack technique ## Technologies
| Couche | Technologie | | Partie | Technologie |
| ----------------- | ---------------------------------------- | | ----------------- | ------------------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS v4 | | Interface | Vue 3, Vite, Tailwind CSS v4 |
| Backend | Express 5 + TypeScript | | Serveur | Express 5, TypeScript |
| Auth | JWT + bcrypt | | Authentification | JWT, bcrypt |
| Worker | Node Worker Threads (organisation async) | | Import | Worker threads Node (import en arrière-plan) |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | | Image Docker | `node:22-slim`, pnpm, gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) | | Binaire | Bun (exécutable autonome, sans Node) |
--- ---
## Liens ## Liens
- [fankai.fr](https://fankai.fr) — Le projet Fankai - [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 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 - [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) : reconnaissance des métadonnées Fankai dans Jellyfin et Emby
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents - [Fankai pour Kodi](https://github.com/Nackophilz/fankai_kodi) : fournisseur de métadonnées Fankai pour Kodi
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel - [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: services:
image: masutayunikon/fankarr:latest fankarr:
container_name: fankarr image: masutayunikon/fankarr:latest
environment: container_name: fankarr
- PUID=1000 # UID de votre utilisateur (id -u) environment:
- PGID=1000 # GID de votre utilisateur (id -g) - PUID=1000 # UID de votre utilisateur (id -u)
- TZ=Europe/Paris # Votre timezone - PGID=1000 # GID de votre utilisateur (id -g)
volumes: - TZ=Europe/Paris # Fuseau horaire
- ./config:/config # Config, logs, base de données volumes:
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles) - ./config:/config # Configuration, journaux et données
ports: - /votre/chemin:/media # Dossier parent de la médiathèque et des téléchargements (nécessaire aux hardlinks)
- 9898:9898 ports:
restart: unless-stopped - 9898:9898
restart: unless-stopped
+2 -2
View File
@@ -4,9 +4,9 @@ set -e
PUID=${PUID:-1000} PUID=${PUID:-1000}
PGID=${PGID:-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 chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js 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"> <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="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#13100c" /> <meta name="theme-color" content="#13100c" />
<title>Fankarr</title> <title>FanKarr</title>
</head> </head>
<body> <body>
<div id="app"></div> <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 RAW_EXPIRY = process.env.AUTH_TOKEN_EXPIRY ?? '30d'
const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never' const NO_EXPIRY = RAW_EXPIRY === '0' || RAW_EXPIRY.toLowerCase() === 'never'
const TOKEN_EXPIRY: string | undefined = NO_EXPIRY ? undefined : RAW_EXPIRY 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) // POST /api/auth/setup (premier lancement uniquement)
export function authSetup(req: Request, res: Response): void { export function authSetup(req: Request, res: Response): void {
if (hasUsers()) { 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 res.status(400).json({ error: 'Un compte existe déjà' }); return
} }
const { username, password } = req.body const { username, password } = req.body
if (!username || !password) { 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) { 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 { try {
const user = createUser(username, password, 'admin') const user = createUser(username, password, 'admin')
startOnboarding() startOnboarding()
logger.info('auth', `Premier compte admin créé pour "${username}"`) logger.info('auth', `Premier compte admin créé pour « ${username} »`)
setCookieAndRespond(res, user) setCookieAndRespond(res, user)
} catch (err) { } 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 const { username, password } = req.body
if (!username || !password) { 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) const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) { 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 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) setCookieAndRespond(res, user)
} }
@@ -115,7 +115,7 @@ export function authLogout(req: Request, res: Response): void {
if (token) { if (token) {
try { try {
const p = jwt.verify(token, JWT_SECRET) as any 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 {} } catch {}
} }
res.clearCookie('fankarr_token') res.clearCookie('fankarr_token')
@@ -131,16 +131,16 @@ export function authMe(req: Request, res: Response): void {
export function authChangePassword(req: Request, res: Response): void { export function authChangePassword(req: Request, res: Response): void {
const { currentPassword, newPassword } = req.body const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) { 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) { 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 { try {
changePassword(req.user!.id, currentPassword, newPassword) changePassword(req.user!.id, currentPassword, newPassword)
res.json({ success: true }) res.json({ success: true })
} catch (err) { } 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) const token = regenerateApiToken(req.user!.id)
res.json({ apiToken: token }) res.json({ apiToken: token })
} catch (err) { } 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 { try {
res.json({ tourSeenAt: markTourSeen(req.user!.id) }) res.json({ tourSeenAt: markTourSeen(req.user!.id) })
} catch (err) { } 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) // ── Contrôle d'accès ─────────────────────────────────────────
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 ───────────────────────────────────────────────
export function requireAuth(req: Request, res: Response, next: NextFunction): void { export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies?.fankarr_token const token = req.cookies?.fankarr_token
if (!token) { if (!token) {
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`) logger.debug('auth', `Accès refusé : non authentifié (${req.method} ${req.path})`)
res.status(401).json({ error: 'Non authentifié' }); return res.status(401).json({ error: 'Session expirée, reconnectez-vous' }); return
} }
try { try {
const payload = jwt.verify(token, JWT_SECRET) as any 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 req.user = user
next() next()
} catch { } catch {
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`) logger.warn('auth', `Jeton de session invalide (${req.method} ${req.path})`)
res.status(401).json({ error: 'Token invalide' }) res.status(401).json({ error: 'Session expirée, reconnectez-vous' })
} }
} }
export function requireAdmin(req: Request, res: Response, next: NextFunction): void { export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
requireAuth(req, res, () => { requireAuth(req, res, () => {
if (req.user?.role !== 'admin') { 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 res.status(403).json({ error: 'Accès réservé aux administrateurs' }); return
} }
next() next()
}) })
} }
/** Accepte aussi l'authentification par token API (header Authorization: Bearer <token>) */
export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void { export function requireAuthOrApiToken(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization const authHeader = req.headers.authorization
if (authHeader?.startsWith('Bearer ')) { 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' export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd() && path.dirname((process as any).execPath) !== process.cwd()
// ── Chargement anticipé du fichier .env pour les binaires bun ───────────── // Charge le .env du binaire Bun avant tout module qui lit process.env
// 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.
;(() => { ;(() => {
if (!_isBunBinary) return if (!_isBunBinary) return
const binDir = path.dirname((process as any).execPath) 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 if (eq === -1) continue
const key = line.slice(0, eq).trim() const key = line.slice(0, eq).trim()
let val = line.slice(eq + 1).trim() let val = line.slice(eq + 1).trim()
// Supprimer les guillemets optionnels
if ((val.startsWith('"') && val.endsWith('"')) || if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) { (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1) 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 if (key && !(key in process.env)) process.env[key] = val
} }
console.log(`[config] .env chargé depuis ${envPath}`) console.log(`[config] .env chargé depuis ${envPath}`)
} catch (err) { } 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() : process.cwd()
const isInContainer = (): boolean => { const isInContainer = (): boolean => {
// Docker
if (fs.existsSync('/.dockerenv')) return true; if (fs.existsSync('/.dockerenv')) return true;
// Podman (crée un fichier /.containerenv) // Podman
if (fs.existsSync('/.containerenv')) return true; if (fs.existsSync('/.containerenv')) return true;
// Vérification via cgroup (Docker, Podman, LXC, etc.)
try { try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8'); const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
if (/docker|podman|containerd|lxc/i.test(cgroup)) return true; if (/docker|podman|containerd|lxc/i.test(cgroup)) return true;
} catch {} } catch {}
// Vérification via /proc/1/environ (fallback général)
try { try {
const environ = fs.readFileSync('/proc/1/environ', 'utf8'); const environ = fs.readFileSync('/proc/1/environ', 'utf8');
if (environ.includes('container=')) return true; if (environ.includes('container=')) return true;
+1 -6
View File
@@ -1,9 +1,4 @@
/** /** Titres de l'API Fankai vers les noms de dossiers du dépôt GitLab des NFO. */
* gitlab-map.ts
* =============
* Map des titres API → titres GitLab pour le téléchargement des NFO.
* Partagé entre organize-worker.ts et index.ts.
*/
export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = { export const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', '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)) app.use(express.static(PUBLIC_PATH))
} }
// ── Migration mono-user → multi-user ────────────────────────── // ── Migration vers le multi-utilisateur ───────────────────────
migrateIfNeeded() migrateIfNeeded()
migrateOnboarding() migrateOnboarding()
// ── Auth ─────────────────────────────────────────────────────── // ── Authentification ───────────────────────────────────────────
app.get ('/api/auth/status', authStatus) app.get ('/api/auth/status', authStatus)
app.post('/api/auth/setup', authSetup) app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin) 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/regenerate-token', requireAuth, authRegenerateToken)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen) app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
// ── API publique v1 (auth par token) ────────────────────────── // ── API publique v1 (authentification par jeton) ──────────────
app.use('/api', publicApiRouter) app.use('/api', publicApiRouter)
// ── Garde admin : protège les préfixes réservés aux admins ──── // ── Garde admin par préfixe ───────────────────────────────────
// NOTE : app.use('/api', requireAdmin, router) s'applique à TOUTES // requireAdmin sur app.use('/api') bloquerait tous les utilisateurs
// 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.
const ADMIN_PREFIXES = [ const ADMIN_PREFIXES = [
'/users', '/jellyfin', '/settings', '/torrent-clients', '/users', '/jellyfin', '/settings', '/torrent-clients',
'/downloads', '/download', '/organize', '/import', '/downloads', '/download', '/organize', '/import',
@@ -108,7 +105,7 @@ app.use('/api', (req, res, next) => {
next() next()
}) })
// ── Routers (admin + utilisateurs connectés) ─────────────────── // ── Routes (admins et utilisateurs connectés) ──────────────────
app.use('/api', usersRouter) app.use('/api', usersRouter)
app.use('/api', jellyfinRouter) app.use('/api', jellyfinRouter)
app.use('/api', settingsRouter) app.use('/api', settingsRouter)
@@ -124,7 +121,7 @@ app.use('/api', invitesRouter)
app.use('/api', requireAuth, seriesRouter) app.use('/api', requireAuth, seriesRouter)
app.use('/api', requireAuth, requestsRouter) app.use('/api', requireAuth, requestsRouter)
// ── Catch-all SPA ────────────────────────────────────────────── // ── Application web (toutes les autres routes) ─────────────────
if (fs.existsSync(PUBLIC_PATH)) { if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) }) 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 () => { server.listen(PORT, async () => {
const url = `http://localhost:${PORT}` const url = `http://localhost:${PORT}`
console.log(`\n FanKarr → \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`) console.log(`\n FanKarr \x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\\n`)
logger.info('api', `Serveur démarré — ${url}`) logger.info('api', `Serveur démarré sur ${url}`)
try { try {
const available = await readAvailable() 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) { } 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() 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 { try {
const raw = readOrganized() const raw = readOrganized()
const isOldFormat = Object.values(raw).some((entries: any) => const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string') Object.values(entries).some(v => typeof v === 'string')
) )
if (isOldFormat) { 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({}) writeOrganized({})
} }
} catch (err) { } 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 { try {
const raw = readOrganized() const raw = readOrganized()
let patched = 0 let patched = 0
@@ -173,10 +169,10 @@ server.listen(PORT, async () => {
} }
if (patched > 0) { if (patched > 0) {
writeOrganized(raw) 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) { } 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() loadCatalog()
@@ -185,7 +181,7 @@ server.listen(PORT, async () => {
dedupeOrganizedEpisodes(ORGANIZED_PATH) dedupeOrganizedEpisodes(ORGANIZED_PATH)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData) 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 () => { const autoOrganize = async () => {
autoImportSchedule.lastRunAt = new Date().toISOString() autoImportSchedule.lastRunAt = new Date().toISOString()
@@ -201,7 +197,7 @@ server.listen(PORT, async () => {
if (result.done > 0 || result.errors > 0) { if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() }) 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) { if (result.done > 0 && result.serieId != null) {
try { try {
const toComplete = readRequests().filter(r => const toComplete = readRequests().filter(r =>
@@ -210,25 +206,25 @@ server.listen(PORT, async () => {
) )
for (const r of toComplete) { for (const r of toComplete) {
completeRequest(r.id) 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) { } 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() const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) { if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
const removeResult = await dispatchRemove(result.hash, false) const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) { 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 { } 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) { } 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) }, 10_000)
setTimeout(() => { 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(() => { 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) }, 60 * 60_000)
}, 30_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 () => { setInterval(async () => {
try { try {
const seriesData = await loadCatalog() const seriesData = await loadCatalog()
const organizedPath = path.join(DATA_DIR, 'organized.json') const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0) 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) const { removed } = dedupeOrganizedEpisodes(organizedPath)
if (removed > 0) 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) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) 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) { } 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) }, 60 * 60_000)
// RSS sync — auto-téléchargement des nouveaux épisodes toutes les 6 heures.
setTimeout(() => { 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(() => { 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) }, 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(() => { 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(() => { 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) }, 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 fs from 'fs'
import path from 'path' import path from 'path'
@@ -20,7 +17,7 @@ export interface Invite {
const INVITES_PATH = path.join(DATA_DIR, 'invites.json') const INVITES_PATH = path.join(DATA_DIR, 'invites.json')
// ── Helpers fichier ─────────────────────────────────────────── // ── Fichier des invitations ───────────────────────────────────
export function readInvites(): Invite[] { export function readInvites(): Invite[] {
try { try {
@@ -34,7 +31,7 @@ function writeInvites(invites: Invite[]): void {
fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2)) fs.writeFileSync(INVITES_PATH, JSON.stringify(invites, null, 2))
} }
// ── CRUD ────────────────────────────────────────────────────── // ── Gestion des invitations ───────────────────────────────────
export function createInvite(opts: { export function createInvite(opts: {
createdBy: string createdBy: string
@@ -49,12 +46,12 @@ export function createInvite(opts: {
expiresAt: opts.expiresInHours expiresAt: opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString() ? new Date(Date.now() + opts.expiresInHours * 3_600_000).toISOString()
: null, : null,
maxUses : opts.maxUses === undefined ? 1 : opts.maxUses, // null = illimité maxUses : opts.maxUses === undefined ? 1 : opts.maxUses,
uses : 0, uses : 0,
note : opts.note, note : opts.note,
} }
writeInvites([...readInvites(), invite]) 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 return invite
} }
@@ -68,10 +65,9 @@ export function findInvite(code: string): Invite | undefined {
return readInvites().find(i => i.code === code) 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 } { export function validateInvite(code: string): { valid: true; invite: Invite } | { valid: false; reason: string } {
const invite = findInvite(code) 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()) { if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { valid: false, reason: 'Ce lien d\'invitation a expiré' } 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 } return { valid: true, invite }
} }
/** Incrémente le compteur d'utilisations */
export function consumeInvite(code: string): void { export function consumeInvite(code: string): void {
const invites = readInvites() const invites = readInvites()
const invite = invites.find(i => i.code === code) 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() }) _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 { export function cacheFetchedAt(key: string): string | null {
const entry = _cache.get(key) const entry = _cache.get(key)
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null 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 } export function cacheSize(): number { return _cache.size }
/** /** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
* (~5 min).
*/
export async function githubGet(urlPath: string, force = false): Promise<any> { export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) { if (!force) {
const cached = cacheGet(urlPath) 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 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 init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init) 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() const data = await res.json()
cacheSet(urlPath, data) cacheSet(urlPath, data)
return 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 { readSettings } from '../settings.js'
import { logger } from '../logger.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`, { const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token), 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() const data = await res.json()
return { ok: true, version: data.Version } return { ok: true, version: data.Version }
} catch (err) { } catch (err) {
@@ -51,15 +47,15 @@ export async function testJellyfinConnection(url: string, token: string): Promis
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> { export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings() 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 base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) }) 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() return res.json()
} }
// ── Validation d'un token utilisateur Jellyfin (SSO) ────────── // ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo { export interface JellyfinTokenInfo {
valid : boolean valid : boolean
@@ -77,13 +73,13 @@ export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToke
const res = await fetch(`${base}/Users/${jellyfinUserId}`, { const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken), headers: userHeaders(jellyfinToken),
}) })
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' } if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` } if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json() const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' } 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 } return { valid: true, userId: user.Id, username: user.Name }
} catch (err) { } catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' } 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++ downloaded++
} catch { skipped++ } } 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() { export async function checkNfoUpdates() {
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
const gitlabTitle = getGitlabTitle(serieTitle) const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle) const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue 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 { try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle)) await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha commits[gitlabTitle] = latest.sha
hasChanges = true hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' }) recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`) 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 }) 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() 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') const raw = fs.readFileSync(file, 'utf-8')
let data: unknown let data: unknown
try { data = JSON.parse(raw) } try { data = JSON.parse(raw) }
catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) } 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('organized.json : contenu inattendu') 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 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 } export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
/** /** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
* 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
*/
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] { export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
const toDownload: RequestTorrent[] = [] const toDownload: RequestTorrent[] = []
const seenHashes = new Set<string>() 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 }) 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[]) { function addEpisodesByIndex(epList: any[]) {
for (const ep of epList) { for (const ep of epList) {
if (ep.torrents?.length > 0) { if (ep.torrents?.length > 0) {
// Premier torrent individuel disponible
addTorrent(ep.torrents[0]) addTorrent(ep.torrents[0])
} else if (ep.paths?.length > 0) { } else if (ep.paths?.length > 0) {
// Premier path dans un pack
const p = ep.paths[0] const p = ep.paths[0]
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase()) const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null) 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 ?? []) { for (const season of serieData.seasons ?? []) {
if (!seasons.includes(season.season_number)) continue if (!seasons.includes(season.season_number)) continue
if ((season.torrents ?? []).length > 0) { if ((season.torrents ?? []).length > 0) {
// Premier pack saison disponible → télécharger en bloc
addTorrent(season.torrents[0]) addTorrent(season.torrents[0])
} else { } else {
// Pas de pack → épisode par épisode avec file_index
addEpisodesByIndex(season.episodes ?? []) addEpisodesByIndex(season.episodes ?? [])
} }
} }
@@ -60,12 +50,10 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
if ((serieData.torrents ?? []).length > 0) { if ((serieData.torrents ?? []).length > 0) {
addTorrent(serieData.torrents[0]) addTorrent(serieData.torrents[0])
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) { } else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
// Packs saison (premier par saison)
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
if (season.torrents?.length > 0) addTorrent(season.torrents[0]) if (season.torrents?.length > 0) addTorrent(season.torrents[0])
} }
} else { } else {
// Fallback : épisode par épisode avec file_index
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
addEpisodesByIndex(season.episodes ?? []) addEpisodesByIndex(season.episodes ?? [])
} }
@@ -75,7 +63,6 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
return toDownload return toDownload
} }
// Épisodes uniques concernés par la même portée
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number { export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
const keys = new Set<string>() const keys = new Set<string>()
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
+15 -28
View File
@@ -1,11 +1,4 @@
/** /** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
* 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.
*/
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
@@ -21,7 +14,7 @@ import { readSettings } from '../settings.js'
export interface SyncedSerie { export interface SyncedSerie {
serieId : number serieId : number
serieName: string 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> type SyncedMap = Record<number, SyncedSerie>
@@ -73,8 +66,7 @@ export function isSynced(serieId: number): boolean {
const normalizeName = (name: string) => const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() 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. // Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
export function reconcileSynced(catalog: any[]): SyncedSerie[] { export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced() const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id)) 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]) { if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId] delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title } 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 changed = true
} else { } else {
orphans.push(entry) orphans.push(entry)
@@ -96,7 +88,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
return orphans return orphans
} }
// ── Helpers ─────────────────────────────────────────────────── // ── Épisodes déjà importés ────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> { function readOrganized(): Record<string, Record<string, any>> {
try { try {
@@ -144,7 +136,7 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
return ids return ids
} }
// ── Logique de sync ─────────────────────────────────────────── // ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> { export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 } 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 synced = loadSynced()
const ids = Object.keys(synced).map(Number) 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() const organized = readOrganized()
@@ -176,7 +168,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId] const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId) const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) { 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 continue
} }
@@ -184,9 +176,8 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const activatedAt = new Date(syncedEntry.addedAt) const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized) 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>() const candidateIds = new Set<number>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { 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) const epDate = new Date(ep.date_added)
if (epDate <= activatedAt) continue if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) 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)) const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue if (!hasTorrent) continue
candidateIds.add(ep.id) candidateIds.add(ep.id)
@@ -202,17 +192,15 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
} }
if (candidateIds.size === 0) { 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 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 toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
const coveredIds = new Set<number>() const coveredIds = new Set<number>()
// 1. Packs saison couvrant au moins un candidat
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) { for (const t of season.torrents ?? []) {
if (!t.fankai) continue 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 season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue 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, infohash : dl.infohash,
}) })
if (results.some((r: any) => r.ok)) { if (results.some((r: any) => r.ok)) {
logger.info('rss-sync', `Envoyé : ${dl.label}`) logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
sent++ sent++
} else { } else {
logger.warn('rss-sync', `Échec envoi : ${dl.label}`) logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
errors++ errors++
} }
} catch (err) { } 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++ 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 } 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 AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
export const autoImportSchedule = { 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> { export async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`) 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() 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`) if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return sd return sd
} catch (err) { } 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 return null
} }
} }
let lastCatalogWarn = 0 let lastCatalogWarn = 0
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées. // Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> { export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)]) const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
@@ -121,14 +120,6 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result 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): { export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null formatted_name : string | null
nfo_filename : 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( export function computeExpectedName(
ep : any, ep : any,
orgEntry: any, orgEntry: any,
@@ -164,11 +151,7 @@ export function computeExpectedName(
return { currentName, expectedName, needsRename: expectedName !== currentName } return { currentName, expectedName, needsRename: expectedName !== currentName }
} }
/** /** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
* 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.
*/
export function deduplicateEpisodes(episodes: any[]): any[] { export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>() const map = new Map<number, any>()
for (const ep of episodes) { 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 } { export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
// Dédoublonnage : épisodes uniques par (season_number, episode_number) // Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
// pour éviter de compter x264 + x265 comme deux épisodes distincts
const uniqueEpKeys = new Set<string>() const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>() const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) { 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 fs from 'fs'
import path from 'path' import path from 'path'
@@ -13,15 +6,15 @@ import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface LogEntry { export interface LogEntry {
at : string // ISO timestamp at : string // date ISO
level : LogLevel level : LogLevel
source : string // 'organize' | 'api' | 'scraper' | ... source : string // 'api', 'organize', 'torrent-clients', 'rss-sync'…
msg : string msg : string
meta? : Record<string, any> meta? : Record<string, any>
} }
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl') 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 ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
const IS_PROD = process.env.NODE_ENV === 'production' 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>) { export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta } const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
// Console
const prefix = `[${source}]` const prefix = `[${source}]`
if (level === 'error') console.error(prefix, msg, meta ?? '') if (level === 'error') console.error(prefix, msg, meta ?? '')
else if (level === 'warn') console.warn(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 === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
else if (level === 'info') 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 if (level === 'debug' && IS_PROD) return
writeLine(entry) writeLine(entry)
} }
@@ -73,9 +64,9 @@ export const logger = {
// ── Lecture pour l'API ──────────────────────────────────────── // ── Lecture pour l'API ────────────────────────────────────────
export interface LogsReadOptions { export interface LogsReadOptions {
limit? : number // nb de lignes (défaut 100) limit? : number // nombre de lignes (100 par défaut)
level? : LogLevel | 'all' // filtre niveau level? : LogLevel | 'all'
source? : string // filtre source source? : string
} }
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] { export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
@@ -84,22 +75,18 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
const content = fs.readFileSync(LOGS_PATH, 'utf-8') const content = fs.readFileSync(LOGS_PATH, 'utf-8')
let lines = content.split('\n').filter(Boolean) let lines = content.split('\n').filter(Boolean)
// Parse
let entries: LogEntry[] = [] let entries: LogEntry[] = []
for (const line of lines) { for (const line of lines) {
try { entries.push(JSON.parse(line)) } catch {} try { entries.push(JSON.parse(line)) } catch {}
} }
// Filtres
if (opts.level && opts.level !== 'all') if (opts.level && opts.level !== 'all')
entries = entries.filter(e => e.level === opts.level) entries = entries.filter(e => e.level === opts.level)
if (opts.source) if (opts.source)
entries = entries.filter(e => e.source === opts.source) entries = entries.filter(e => e.source === opts.source)
// Plus récents en premier
entries.reverse() entries.reverse()
// Limite
const limit = opts.limit ?? 100 const limit = opts.limit ?? 100
return entries.slice(0, limit) return entries.slice(0, limit)
} catch { return [] } } catch { return [] }
+2 -2
View File
@@ -20,7 +20,7 @@ export function readOnboarding(): OnboardingState {
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt } 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 { export function migrateOnboarding(): boolean {
const settings = readSettings() const settings = readSettings()
if (settings.onboardingCompletedAt || settings.onboardingStep) return false if (settings.onboardingCompletedAt || settings.onboardingStep) return false
@@ -29,7 +29,7 @@ export function migrateOnboarding(): boolean {
const now = new Date().toISOString() const now = new Date().toISOString()
writeSettings({ onboardingCompletedAt: now }) writeSettings({ onboardingCompletedAt: now })
markAllToursSeen(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 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 fs from 'fs'
import fsp from 'fs/promises' import fsp from 'fs/promises'
@@ -12,7 +7,7 @@ import { DATA_DIR } from './config.js'
import { getGitlabTitle } from './gitlab-map.js' import { getGitlabTitle } from './gitlab-map.js'
import { readOrganized } from './lib/organized-store.js' import { readOrganized } from './lib/organized-store.js'
// ─── Utils log ──────────────────────────────────────────────── // ─── Journal ──────────────────────────────────────────────────
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) } function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', 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>> // organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>> type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ───────────────────────────────────────────────── // ─── Réglages ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } { function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean, englishDirectory: boolean } {
try { try {
const p = path.join(DATA_DIR, 'settings.json') 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 } } } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false , englishDirectory: false } }
} }
// ─── Organized log ──────────────────────────────────────────── // ─── Suivi des imports ────────────────────────────────────────
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) { function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
parentPort?.postMessage({ type: 'mark', hash, episodeId, entry }) parentPort?.postMessage({ type: 'mark', hash, episodeId, entry })
} }
@@ -51,11 +46,8 @@ function isOrganized(hash: string, episodeId: number): boolean {
return !!readOrganized()[hash]?.[String(episodeId)] 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 { function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase() const h = hash.toLowerCase()
for (const sd of seriesData) { for (const sd of seriesData) {
@@ -94,11 +86,7 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
return null return null
} }
/** /** Hash non résolu (ex. dbid_X Synology) : recherche par torrent_name ou title, renvoie le vrai infohash. */
* 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é.
*/
function findTorrentByName( function findTorrentByName(
name : string, name : string,
seriesData: any[], seriesData: any[],
@@ -117,7 +105,6 @@ function findTorrentByName(
} }
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
// Pack saison
for (const t of season.torrents ?? []) { for (const t of season.torrents ?? []) {
if (!t.infohash) continue if (!t.infohash) continue
if ( if (
@@ -126,7 +113,6 @@ function findTorrentByName(
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() } ) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
} }
// Torrent épisode unique
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) { for (const t of ep.torrents ?? []) {
if (!t.infohash) continue if (!t.infohash) continue
@@ -170,7 +156,6 @@ function buildFileMap(
if (!matchedPath) continue if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath 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 rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
@@ -179,7 +164,7 @@ function buildFileMap(
: null : null
if (!nfoSupport && !fmtName) { 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) const srcExt = path.extname(filename)
@@ -218,7 +203,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false 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_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' 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> { async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabTitle(serieTitle) const gitlabTitle = getGitlabTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}` 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[] = [] let files: any[] = []
try { try {
@@ -254,14 +239,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
page++ page++
} }
} catch (err) { } 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 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') 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 let downloaded = 0, skipped = 0, failed = 0
for (const entry of fileEntries) { for (const entry of fileEntries) {
@@ -275,14 +260,14 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
fs.writeFileSync(destPath, data) fs.writeFileSync(destPath, data)
downloaded++ downloaded++
} catch (err) { } 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++ 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 { function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename) 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}`) return n === 0 ? 'Specials' : (englishDirectory ? `Season ${String(n).padStart(2, '0')}` : `Saison ${n}`)
} }
/** /** Supprime un fichier en réessayant si EBUSY (fichier ouvert sous Windows). */
* 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.
*/
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> { async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
try { try {
@@ -306,7 +288,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
return return
} catch (err: any) { } catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) { 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)) await new Promise(r => setTimeout(r, 500))
} else { } else {
throw err throw err
@@ -318,7 +300,7 @@ async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void>
function tryHardlink(src: string, dest: string): boolean { function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true } try { fs.linkSync(src, dest); return true }
catch (err) { 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 return false
} }
} }
@@ -331,7 +313,7 @@ function sanitizeDirName(name: string): string {
.trim() .trim()
} }
// ─── Organise un torrent ────────────────────────────────────── // ─── Import d'un torrent ──────────────────────────────────────
async function organizeTorrent( async function organizeTorrent(
hash : string, hash : string,
name : string, name : string,
@@ -344,7 +326,7 @@ async function organizeTorrent(
const found = findTorrentByHash(hash, seriesData) const found = findTorrentByHash(hash, seriesData)
if (!found) { 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}`) throw new Error(`Torrent introuvable : ${hash}`)
} }
@@ -352,13 +334,13 @@ async function organizeTorrent(
const rawTitle = serieData.title ?? serieData.show_title ?? name const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(rawTitle) 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) { if (nfoSupport) {
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle)) await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
} }
// ── Cas torrent épisode unique ──────────────────────────── // ── Torrent d'un seul épisode ─────────────────────────────
if (torrent._episode) { if (torrent._episode) {
const ep = torrent._episode const ep = torrent._episode
const season = torrent._season const season = torrent._season
@@ -374,7 +356,6 @@ async function organizeTorrent(
} }
} }
const filename = filePath?.split('/').pop() ?? name 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 rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_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 } 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())) { 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 } return { ...result, total: 1, skipped: 1 }
} }
@@ -414,7 +394,7 @@ async function organizeTorrent(
if (!src) { if (!src) {
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`) 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)) { if (!fs.existsSync(dest)) {
@@ -431,7 +411,7 @@ async function organizeTorrent(
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir, src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir,
}) })
await unlinkWithRetry(src) await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`) log(`${destName} importé dans ${path.basename(destDir)}`)
return { ...result, total: 1, done: 1 } return { ...result, total: 1, done: 1 }
} }
} }
@@ -441,16 +421,16 @@ async function organizeTorrent(
episode: ep.episode_number, episode_id: ep.id, episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest, dest_dir: destDir, 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 } return { ...result, total: 1, done: 1 }
} }
// ── Pack saison / intégrale ─────────────────────────────── // ── Pack saison ou intégrale ──────────────────────────────
const seasonFilter = torrent._season?.season_number const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter) const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
if (fileMap.size === 0) { if (fileMap.size === 0) {
warn(`Aucun fichier mappé pour "${name}"`) warn(`Aucun fichier de « ${name} » ne correspond au catalogue`)
return result return result
} }
@@ -459,10 +439,8 @@ async function organizeTorrent(
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) { for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue } 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())) { 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++ result.skipped++
continue continue
} }
@@ -477,8 +455,8 @@ async function organizeTorrent(
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) { if (!src) {
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0). // 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')}`) warn(`Ignoré (fichier absent du disque) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
result.skipped++ result.skipped++
continue continue
} }
@@ -522,10 +500,10 @@ async function organizeTorrent(
await unlinkWithRetry(src) await unlinkWithRetry(src)
} }
result.done++ result.done++
debug(`${nfo_filename} → Saison ${season_number}`) debug(`${nfo_filename} importé dans ${path.basename(destDir)}`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
error(`Échec import "${filename}" : ${msg}`) error(`Échec de l'import de « ${filename} » : ${msg}`)
result.errors.push({ file: filename, error: msg }) result.errors.push({ file: filename, error: msg })
} }
} }
@@ -552,24 +530,21 @@ parentPort?.on('message', async (msg: any) => {
for (const t of torrents) { for (const t of torrents) {
if (t.state !== 'seeding') continue if (t.state !== 'seeding') continue
// Résolution principale : par infohash
let found = findTorrentByHash(t.hash, seriesData) let found = findTorrentByHash(t.hash, seriesData)
let effectiveHash = t.hash let effectiveHash = t.hash
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche // Hash non résolu (ex. dbid_X Synology) : recherche par nom de dossier
// 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.
if (!found && t.name) { if (!found && t.name) {
const byName = findTorrentByName(t.name, seriesData) const byName = findTorrentByName(t.name, seriesData)
if (byName) { if (byName) {
found = byName found = byName
effectiveHash = byName.infohash 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) { 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 continue
} }
@@ -582,25 +557,20 @@ parentPort?.on('message', async (msg: any) => {
: Object.keys(orgHash).length > 0 : Object.keys(orgHash).length > 0
if (allDone) { if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`) debug(`« ${t.name} » déjà entièrement importé`)
continue continue
} }
try { try {
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression. // Fichiers terminés (nom seul) ; null si le client ne donne pas les noms
// 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.
let completedFileNames: Set<string> | null = null let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) { 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()) const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) { if (hasNames) {
completedFileNames = new Set( completedFileNames = new Set(
t.files t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim()) .filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => { .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('/') const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase() 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) 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 }) parentPort?.postMessage({ type: 'result', hash: effectiveHash, name: t.name, serieId: serieData.id ?? null, ...result })
} catch (err) { } 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 fs from 'fs'
import path from 'path' import path from 'path'
@@ -63,7 +58,7 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
try { try {
updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry }) updateOrganized(data => { (data[msg.hash] ??= {})[String(msg.episodeId)] = msg.entry })
} catch (err) { } 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') { } else if (msg.type === 'result') {
onResult(msg) onResult(msg)
@@ -72,13 +67,13 @@ function runWorker(torrents: any[], seriesData: any[], onResult: (r: WorkerResul
} }
}) })
worker.on('error', err => finish(err)) 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 }) worker.postMessage({ type: 'run', torrents, seriesData })
}) })
} }
// ── Scan initial ────────────────────────────────────────────── // ── Analyse de la médiathèque ─────────────────────────────────
function swapExt(filename: string, ext: string): string { function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename) const cur = path.extname(filename)
@@ -96,11 +91,11 @@ export async function scanMediaPath(
const result = { found: 0, added: 0 } const result = { found: 0, added: 0 }
if (!fs.existsSync(mediaPath)) { 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 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, { const filenameIndex = new Map<string, {
hashes : string[] hashes : string[]
@@ -115,7 +110,6 @@ export async function scanMediaPath(
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
// Helper : indexer par nom sans extension
const idx = (filename: string, hash: string, destFilename?: string) => { const idx = (filename: string, hash: string, destFilename?: string) => {
const base = filename.replace(/\.[^.]+$/, '') const base = filename.replace(/\.[^.]+$/, '')
if (!base) return 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 ?? []) { for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash || !p.path) continue if (typeof p !== 'object' || !p.infohash || !p.path) continue
const hash = p.infohash.toLowerCase() const hash = p.infohash.toLowerCase()
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop() const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue if (!srcFilename) continue
idx(srcFilename, hash) 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 const pathNfo = p.nfo_filename ?? ep.nfo_filename
if (pathNfo) idx(pathNfo, hash, pathNfo) if (pathNfo) idx(pathNfo, hash, pathNfo)
const pathFmt = p.formatted_name ?? ep.formatted_name 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' const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename) if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename) if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
@@ -166,11 +157,10 @@ export async function scanMediaPath(
let organized: Organized let organized: Organized
try { organized = readOrganized(organizedPath) } try { organized = readOrganized(organizedPath) }
catch (err) { 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 return result
} }
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>() const presentFiles = new Set<string>()
let noMatch = 0 let noMatch = 0
@@ -205,8 +195,7 @@ export async function scanMediaPath(
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '') const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
let match = filenameIndex.get(nameWithoutExt) let match = filenameIndex.get(nameWithoutExt)
// Fallback : chercher si un candidat de l'index est un préfixe du nom du fichier // À défaut, un nom de l'index qui préfixe le fichier (ex. "X.S01E01.MULTI.1080p" pour "X.S01E01.MULTI.1080p.x264-FANKAI")
// ex: index a "Wind Breaker.S01E01.MULTI.1080p" et le fichier est "Wind Breaker.S01E01.MULTI.1080p.x264-FANKAI"
if (!match) { if (!match) {
const lower = nameWithoutExt.toLowerCase() const lower = nameWithoutExt.toLowerCase()
for (const [key, val] of filenameIndex.entries()) { for (const [key, val] of filenameIndex.entries()) {
@@ -221,8 +210,8 @@ export async function scanMediaPath(
noMatch++ noMatch++
const prefix = nameWithoutExt.slice(0, 20).toLowerCase() const prefix = nameWithoutExt.slice(0, 20).toLowerCase()
const close = [...filenameIndex.keys()].filter(k => k.toLowerCase().startsWith(prefix)).slice(0, 3) 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' const closeStr = close.length > 0 ? ` (candidats : ${close.map(c => `« ${c} »`).join(', ')})` : ' (aucun candidat proche)'
logger.warn('organize', `Scan no match : "${nameWithoutExt}"${closeStr}`) logger.warn('organize', `Analyse : aucune correspondance pour « ${nameWithoutExt} »${closeStr}`)
continue continue
} }
@@ -239,11 +228,11 @@ export async function scanMediaPath(
episode, episode,
episode_id : episodeId, episode_id : episodeId,
src_filename : srcFilename, src_filename : srcFilename,
dest_filename: entry.name, // vrai nom sur disque dest_filename: entry.name,
dest_path : full, dest_path : full,
} }
result.added++ 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) walk(mediaPath)
const mediaEmpty = result.found === 0 && Object.keys(organized).length > 0 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 // Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set( const allEpisodeIds = new Set(
@@ -270,7 +259,6 @@ export async function scanMediaPath(
if (Object.keys(organized[hash]).length === 0) delete organized[hash] if (Object.keys(organized[hash]).length === 0) delete organized[hash]
} }
// Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() const { autoUnimportMissing } = readSettings()
let autoRemoved = 0 let autoRemoved = 0
if (autoUnimportMissing && !mediaEmpty) { if (autoUnimportMissing && !mediaEmpty) {
@@ -280,7 +268,7 @@ export async function scanMediaPath(
if (destPath && !fs.existsSync(destPath)) { if (destPath && !fs.existsSync(destPath)) {
delete (organized[hash] as any)[episodeId] delete (organized[hash] as any)[episodeId]
autoRemoved++ 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] if (Object.keys(organized[hash]).length === 0) delete organized[hash]
@@ -292,12 +280,12 @@ export async function scanMediaPath(
} }
lastScan = { at: new Date().toISOString(), ...result } 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 return result
} }
// ── Auto-import ─────────────────────────────────────────────── // ── Import automatique ────────────────────────────────────────
export async function autoOrganizeAll( export async function autoOrganizeAll(
listFn : () => Promise<any[]>, listFn : () => Promise<any[]>,
@@ -306,16 +294,16 @@ export async function autoOrganizeAll(
): Promise<void> { ): Promise<void> {
const { autoImport, mediaPath } = readSettings() const { autoImport, mediaPath } = readSettings()
if (!autoImport) { if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip') logger.debug('organize', 'Import automatique désactivé')
return return
} }
if (!mediaPath?.trim()) { if (!mediaPath?.trim()) {
logger.debug('organize', 'Médiathèque non configurée — skip') logger.debug('organize', 'Médiathèque non configurée')
return return
} }
if (_queued > 0) { if (_queued > 0) {
logger.debug('organize', 'Import déjà en cours — skip') logger.debug('organize', 'Import déjà en cours')
return return
} }
@@ -354,25 +342,25 @@ export async function autoOrganizeAll(
if (seedingCount === 0) return if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) { 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) { for (const t of newlySeeding) {
logger.debug('organize', `Nouveau seeding : "${t.name}"`) logger.debug('organize', `Torrent terminé : « ${t.name} »`)
} }
} else { } 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 { try {
await enqueue(() => runWorker(torrents, seriesData, msg => { await enqueue(() => runWorker(torrents, seriesData, msg => {
if (msg.errors.length > 0) { 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) { 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) { } 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 { } else {
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`) logger.debug('organize', `« ${msg.name} » : rien à importer (${msg.skipped} ignoré(s))`)
} }
onResult?.({ onResult?.({
hash : msg.hash, hash : msg.hash,
@@ -384,25 +372,21 @@ export async function autoOrganizeAll(
errorFiles: msg.errors, errorFiles: msg.errors,
}) })
})) }))
logger.debug('organize', 'Worker terminé') logger.debug('organize', 'Worker d\'import terminé')
} catch (err) { } 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 ──────────────────────────────── // ── Migration des IDs d'épisodes ──────────────────────────────
// // Les IDs du scraper changent : on les retrouve via (infohash, saison, épisode)
// 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.
export async function migrateOrganizedEpisodeIds( export async function migrateOrganizedEpisodeIds(
organizedPath: string, organizedPath: string,
seriesData : any[] seriesData : any[]
): Promise<{ updated: number; orphaned: number }> { ): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel // infohash : Map<"saison:épisode", ID actuel>
// hashEpMap : infohash → Map<"S:E", new_episode_id>
const hashEpMap = new Map<string, Map<string, number>>() const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>() const validIds = new Set<number>()
@@ -421,11 +405,10 @@ export async function migrateOrganizedEpisodeIds(
} }
} }
// 2. Charger organized.json
let organized: Organized let organized: Organized
try { organized = readOrganized(organizedPath) } try { organized = readOrganized(organizedPath) }
catch (err) { 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 } return { updated: 0, orphaned: 0 }
} }
@@ -442,51 +425,45 @@ export async function migrateOrganizedEpisodeIds(
const oldId = Number(oldIdStr) const oldId = Number(oldIdStr)
if (validIds.has(oldId)) { if (validIds.has(oldId)) {
// ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry newEpisodes[oldIdStr] = entry
continue continue
} }
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}` const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key) const newId = epMap?.get(key)
if (newId && newId !== oldId) { 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)]) { 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 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 { } else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId } newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++ updated++
changed = true 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 { } else {
// Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++ orphaned++
newEpisodes[oldIdStr] = entry 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 organized[hash] = newEpisodes
// Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash] if (Object.keys(organized[hash]).length === 0) delete organized[hash]
} }
if (changed) { if (changed) {
writeOrganized(organized, organizedPath) 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 { } 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 } return { updated, orphaned }
} }
// ── Déduplication ───────────────────────────────────────────── // ── Dédoublonnage ─────────────────────────────────────────────
export function dedupeOrganizedEpisodes( export function dedupeOrganizedEpisodes(
organizedPath: string, organizedPath: string,
): { removed: number } { ): { removed: number } {
@@ -497,7 +474,7 @@ export function dedupeOrganizedEpisodes(
return { removed: 0 } return { removed: 0 }
} }
// episode_id → [{ hash, entry }] // episode_id : [{ hash, entry }]
const byEpisode = new Map<string, { hash: string; entry: any }[]>() const byEpisode = new Map<string, { hash: string; entry: any }[]>()
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) { for (const [episodeId, entry] of Object.entries(episodes as Record<string, any>)) {
@@ -517,19 +494,19 @@ export function dedupeOrganizedEpisodes(
if (present.includes(c)) continue if (present.includes(c)) continue
delete (organized[c.hash] as any)[episodeId] delete (organized[c.hash] as any)[episodeId]
removed++ 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) { if (removed > 0) {
writeOrganized(organized, organizedPath) 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 } return { removed }
} }
// ── Sync des noms de fichiers après mise à jour du scraper ──── // ── Renommage selon le catalogue ──────────────────────────────
export async function syncFilenameChanges( export async function syncFilenameChanges(
seriesData : any[], seriesData : any[],
@@ -539,7 +516,7 @@ export async function syncFilenameChanges(
let organized: Organized let organized: Organized
try { organized = readOrganized(organizedPath) } try { organized = readOrganized(organizedPath) }
catch (err) { 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 } return { renamed: 0, errors: 0 }
} }
@@ -565,7 +542,6 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path) ? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename : orgEntry.dest_filename
const srcExt = path.extname(currentName) const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) => const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
) )
@@ -583,7 +559,7 @@ export async function syncFilenameChanges(
const newPath = path.join(path.dirname(oldPath), expectedName) const newPath = path.join(path.dirname(oldPath), expectedName)
try { try {
if (fs.existsSync(newPath)) { if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`) logger.warn('organize', `Renommage ignoré : « ${expectedName} » existe déjà`)
continue continue
} }
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
@@ -596,10 +572,10 @@ export async function syncFilenameChanges(
retargetEntries(organized, new Map([[oldPath, newPath]])) retargetEntries(organized, new Map([[oldPath, newPath]]))
renamed++ renamed++
changed = true changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`) logger.info('organize', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) { } catch (err) {
errors++ 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) writeOrganized(organized, organizedPath)
if (renamed > 0 || errors > 0) 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 } return { renamed, errors }
} }
@@ -626,14 +602,12 @@ export async function organizeTorrent(
const key = hash.toLowerCase() const key = hash.toLowerCase()
const pending = _pendingManual.get(key) const pending = _pendingManual.get(key)
if (pending) { 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 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. // Progression par fichier : le worker ignore les fichiers incomplets (évite EBUSY sous Windows)
// 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.
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] } const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding', files: files ?? [] }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] } const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
@@ -647,16 +621,16 @@ export async function organizeTorrent(
result.done = msg.done result.done = msg.done
result.errors = msg.errors result.errors = msg.errors
if (msg.errors.length > 0) { 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) { 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 { } 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) { } 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 throw err
} }
return result return result
+14 -27
View File
@@ -1,6 +1,3 @@
/**
* Système de demandes utilisateurs
*/
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
@@ -14,8 +11,8 @@ export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
export interface Requester { export interface Requester {
userId : string userId : string
username : string username : string
seasons : number[] // numéros de saison ; [] = toutes les saisons dispo seasons : number[] // numéros de saison ; [] = toutes les saisons disponibles
episodes : number[] // IDs d'épisodes spécifiques ; [] = pas d'épisode ciblé episodes : number[] // IDs d'épisodes ciblés ; [] = aucun
requestedAt: string requestedAt: string
} }
@@ -32,7 +29,7 @@ export interface SerieRequest {
const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json') const REQUESTS_PATH = path.join(DATA_DIR, 'requests.json')
// ── Helpers fichier ─────────────────────────────────────────── // ── Fichier des demandes ──────────────────────────────────────
export function readRequests(): SerieRequest[] { export function readRequests(): SerieRequest[] {
try { try {
@@ -46,7 +43,7 @@ function writeRequests(requests: SerieRequest[]): void {
fs.writeFileSync(REQUESTS_PATH, JSON.stringify(requests, null, 2)) 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[] { export function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return [] if (req.requesters.some(r => r.seasons.length === 0 && r.episodes.length === 0)) return []
const all = new Set<number>() const all = new Set<number>()
@@ -54,33 +51,26 @@ export function mergedSeasons(req: SerieRequest): number[] {
return [...all].sort((a, b) => a - b) return [...all].sort((a, b) => a - b)
} }
/** Union des IDs d'épisodes spécifiques sur tous les requesters */
export function mergedEpisodes(req: SerieRequest): number[] { export function mergedEpisodes(req: SerieRequest): number[] {
const all = new Set<number>() const all = new Set<number>()
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e))) req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
return [...all].sort((a, b) => a - b) 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( export function upsertRequest(
userId : string, userId : string,
serieId : number, serieId : number,
serieName: string, serieName: string,
seasons : number[], // [] = toutes les saisons seasons : number[], // [] = toutes les saisons
episodes : number[] = [], // IDs d'épisodes spécifiques episodes : number[] = [],
): SerieRequest { ): SerieRequest {
const user = findById(userId) const user = findById(userId)
if (!user) throw new Error('Utilisateur introuvable') if (!user) throw new Error('Utilisateur introuvable')
const requests = readRequests() const requests = readRequests()
// Chercher une demande active (pas rejected ni completed) pour cette série
const existing = requests.find(r => const existing = requests.find(r =>
r.serieId === serieId && r.serieId === serieId &&
r.status !== 'rejected' && r.status !== 'rejected' &&
@@ -90,15 +80,13 @@ export function upsertRequest(
const now = new Date().toISOString() const now = new Date().toISOString()
if (existing) { if (existing) {
// Mettre à jour ou ajouter ce requester dans la demande existante
const idx = existing.requesters.findIndex(r => r.userId === userId) const idx = existing.requesters.findIndex(r => r.userId === userId)
if (idx >= 0) { if (idx >= 0) {
const r = existing.requesters[idx] const r = existing.requesters[idx]
// "Tout" = seasons=[] ET episodes=[] (aucune granularité)
const newIsAll = seasons.length === 0 && episodes.length === 0 const newIsAll = seasons.length === 0 && episodes.length === 0
const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0 const curIsAll = r.seasons.length === 0 && (r.episodes ?? []).length === 0
if (newIsAll || curIsAll) { 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.seasons = []
r.episodes = [] r.episodes = []
} else { } else {
@@ -111,11 +99,10 @@ export function upsertRequest(
} }
existing.updatedAt = now existing.updatedAt = now
writeRequests(requests) 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 return existing
} }
// Nouvelle demande
const request: SerieRequest = { const request: SerieRequest = {
id : crypto.randomUUID(), id : crypto.randomUUID(),
serieId, serieId,
@@ -126,7 +113,7 @@ export function upsertRequest(
updatedAt : now, updatedAt : now,
} }
writeRequests([...requests, request]) 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 return request
} }
@@ -138,7 +125,7 @@ export function approveRequest(id: string): SerieRequest {
req.rejectionMessage = undefined req.rejectionMessage = undefined
req.updatedAt = new Date().toISOString() req.updatedAt = new Date().toISOString()
writeRequests(requests) writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" approuvée`) logger.info('requests', `Demande « ${req.serieName} » approuvée`)
return req return req
} }
@@ -150,7 +137,7 @@ export function rejectRequest(id: string, message?: string): SerieRequest {
req.rejectionMessage = message req.rejectionMessage = message
req.updatedAt = new Date().toISOString() req.updatedAt = new Date().toISOString()
writeRequests(requests) writeRequests(requests)
logger.info('requests', `Demande "${req.serieName}" refusée`) logger.info('requests', `Demande « ${req.serieName} » refusée`)
return req return req
} }
@@ -170,7 +157,7 @@ export function deleteRequest(id: string): void {
writeRequests(requests.filter(r => r.id !== id)) 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 } { export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
const requests = readRequests() const requests = readRequests()
const req = requests.find(r => r.id === id) 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) req.requesters = req.requesters.filter(r => r.userId !== userId)
if (req.requesters.length === 0) { if (req.requesters.length === 0) {
writeRequests(requests.filter(r => r.id !== id)) 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 } return { deleted: true, request: null }
} }
req.updatedAt = new Date().toISOString() req.updatedAt = new Date().toISOString()
writeRequests(requests) 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 } 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 torrent_url: string | null = req.body.torrent_url ?? null
const magnet : string | null = req.body.magnet ?? null const magnet : string | null = req.body.magnet ?? null
const url = torrent_url ?? magnet 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_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 file_path : string | null = req.body.file_path ?? null
const infohash : string | null = req.body.infohash ?? 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) => { router.delete('/torrent/:hash', requireAuth, async (req, res) => {
const hash = String(req.params.hash) const hash = String(req.params.hash)
const deleteFiles = req.query.deleteFiles === 'true' 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 { try {
const result = await dispatchRemove(hash, deleteFiles) const result = await dispatchRemove(hash, deleteFiles)
res.json(result) res.json(result)
} catch (err) { } catch (err) {
logger.error('api', `Suppression torrent échouée : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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) res.json(enriched)
} catch (err) { } catch (err) {
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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 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) // 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 { 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) => { router.post('/manual-import', requireAuth, async (req, res) => {
const { serie_id, items } = req.body const { serie_id, items } = req.body
if (!serie_id || !Array.isArray(items) || items.length === 0) { 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() const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 }
// Les copies de fichiers sont asynchrones : les modifications sont rejouées sur une relecture fraîche à la fin // Copies asynchrones : modifications réappliquées sur le fichier relu à la fin
const ops: ((data: Organized) => void)[] = [] const ops: ((data: Organized) => void)[] = []
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) } const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
const sd = await resolveSerieData(Number(serie_id), 'Import manuel') const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
@@ -125,12 +127,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
try { try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable') 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 // Réassignation : retirer les entrées qui pointent ce fichier sous un autre épisode
// sous un épisode différent (réassignation → l'ancien enregistrement devient invalide)
for (const [h, eps] of Object.entries(organized)) { for (const [h, eps] of Object.entries(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === file_path && Number(epId) !== Number(episode_id)) 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 => { apply(data => {
@@ -143,16 +144,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (file_path !== destPath) { if (file_path !== destPath) {
const serieRootPath = path.join(mediaPath, serieTitle) const serieRootPath = path.join(mediaPath, serieTitle)
const isInSeriePath = file_path.startsWith(serieRootPath + path.sep) || file_path.startsWith(serieRootPath + '/') 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) { if (isInSeriePath && fs.existsSync(destPath) && destPath !== file_path) {
fs.unlinkSync(destPath) fs.unlinkSync(destPath)
logger.info('api', `Import manuel (réassignation) : suppression ancien fichier "${destFilename}"`) logger.info('api', `Import manuel (réassignation) : suppression de l'ancien fichier « ${destFilename} »`)
// Nettoyer toute entrée organized qui pointait sur ce fichier supprimé
for (const eps of Object.values(organized)) { for (const eps of Object.values(organized)) {
for (const [epId, entry] of Object.entries(eps)) { for (const [epId, entry] of Object.entries(eps)) {
if (entry?.dest_path === destPath) 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 => { apply(data => {
@@ -164,7 +162,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
if (isInSeriePath && !fs.existsSync(destPath)) { if (isInSeriePath && !fs.existsSync(destPath)) {
fs.renameSync(file_path, destPath) fs.renameSync(file_path, destPath)
apply(data => { retargetEntries(data, new Map([[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)) { } else if (!isInSeriePath && !fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') { if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) } try { fs.linkSync(file_path, destPath) }
@@ -176,10 +174,10 @@ router.post('/manual-import', requireAuth, async (req, res) => {
} else { } else {
await fs.promises.copyFile(file_path, destPath) await fs.promises.copyFile(file_path, destPath)
} }
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`) logger.info('api', `Import manuel : « ${srcFilename} » importé vers « ${destPath} »`)
} }
} else { } 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 torrentHash = String(hash || '').toLowerCase() || 'manual'
const entry = { const entry = {
@@ -190,16 +188,16 @@ router.post('/manual-import', requireAuth, async (req, res) => {
apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry }) apply(data => { (data[torrentHash] ??= {})[String(episode_id)] = entry })
done.push(episode_id) done.push(episode_id)
} catch (err) { } 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 }) 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) { if (ops.length > 0) {
try { updateOrganized(data => { for (const op of ops) op(data) }) } try { updateOrganized(data => { for (const op of ops) op(data) }) }
catch (err) { catch (err) {
logger.error('api', `Import manuel : enregistrement impossible : ${err instanceof Error ? err.message : err}`) 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.json illisible' }); return 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.mkdirSync(path.dirname(dest), { recursive: true })
fs.writeFileSync(dest, Buffer.from(await raw.arrayBuffer())) 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) { } 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 }) 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) => { router.get('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId) const serieId = Number(req.params.serieId)
try { try {
@@ -266,20 +264,20 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
} }
res.json(result) res.json(result)
} catch (err) { } catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
} }
}) })
// ── Rename épisode ───────────────────────────────────────────── // ── Renommage d'un épisode ─────────────────────────────────────
router.post('/rename-episode', requireAuth, async (req, res) => { router.post('/rename-episode', requireAuth, async (req, res) => {
const { serie_id, episode_id, torrent_hash } = req.body 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 { nfoSupport } = readSettings()
const sd = await resolveSerieData(Number(serie_id), 'Renommage') const sd = await resolveSerieData(Number(serie_id), 'Renommage')
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return } if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 let foundEp: any = null
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
@@ -302,30 +300,30 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
const oldPath = orgEntry.dest_path const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName) const newPath = path.join(path.dirname(oldPath), newName)
try { try {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque') if (!fs.existsSync(oldPath)) throw new Error('Fichier introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`) if (fs.existsSync(newPath)) throw new Error(`Un fichier nommé « ${newName} » existe déjà`)
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() } organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]])) retargetEntries(organized, new Map([[oldPath, newPath]]))
writeOrganized(organized) 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 }) res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Rename échoué pour ep ${episode_id} : ${msg}`) logger.error('api', `Échec du renommage de l'épisode ${episode_id} : ${msg}`)
res.status(500).json({ error: 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) => { router.delete('/organized/:serieId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const deleteFile = req.query.deleteFile === 'true' 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 } if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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>() const episodeIds = new Set<string>()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) episodeIds.add(String(ep.id)) 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 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}`)) 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)) { if (serieFolder && fs.existsSync(serieFolder)) {
try { try {
fs.rmSync(serieFolder, { recursive: true, force: true }) 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) { } 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') const completedRequests = readRequests().filter(r => r.serieId === Number(serieId) && r.status === 'completed')
for (const r of completedRequests) { for (const r of completedRequests) {
try { deleteRequest(r.id) } catch {} try { deleteRequest(r.id) } catch {}
} }
if (completedRequests.length > 0) 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 }) res.json({ ok: true, removed, errors })
}) })
// ── Désimport saison ─────────────────────────────────────────── // ── Retrait d'une saison ───────────────────────────────────────
router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => { router.delete('/organized/:serieId/seasons/:seasonId', requireAuth, async (req, res) => {
const serieId = String(req.params.serieId) const serieId = String(req.params.serieId)
const seasonId = Number(req.params.seasonId) const seasonId = Number(req.params.seasonId)
const deleteFile = req.query.deleteFile === 'true' 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 } if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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) const season = sd.seasons?.find((s: any) => s.id === seasonId)
if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return } if (!season) { res.status(404).json({ error: 'Saison introuvable' }); return }
const episodeIds = new Set<string>(season.episodes?.map((e: any) => String(e.id)) ?? []) 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}`)) 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 }) res.json({ ok: true, removed, errors })
}) })
// ── Désimport épisode ────────────────────────────────────────── // ── Retrait d'un épisode ───────────────────────────────────────
router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => { router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) => {
const episodeId = String(req.params.episodeId) const episodeId = String(req.params.episodeId)
const deleteFile = req.query.deleteFile === 'true' const deleteFile = req.query.deleteFile === 'true'
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 }[] = [] const found: { hash: string; entry: any }[] = []
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
if (episodes[episodeId]) found.push({ hash, entry: episodes[episodeId] }) 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 if (!p || deletedPaths.has(p) || !fs.existsSync(p)) continue
fs.unlinkSync(p) fs.unlinkSync(p)
deletedPaths.add(p) deletedPaths.add(p)
logger.info('api', `Désimport + suppression fichier : "${p}"`) logger.info('api', `Retrait et suppression du fichier « ${p} »`)
} }
} }
const emptyHashes: string[] = [] 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) } if (Object.keys(organized[hash]).length === 0) { delete organized[hash]; emptyHashes.push(hash) }
} }
writeOrganized(organized) writeOrganized(organized)
// Si plus aucun épisode d'un torrent n'est importé → retirer ce torrent du client
if (deleteFile) { if (deleteFile) {
for (const hash of emptyHashes) { for (const hash of emptyHashes) {
if (hash === 'manual') continue 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') : [] 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 }) res.json({ ok: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Désimport échoué : ${msg}`) logger.error('api', `Échec du retrait de l'épisode ${episodeId} : ${msg}`)
res.status(500).json({ error: msg }) res.status(500).json({ error: msg })
} }
}) })
// ── Récap global des imports ─────────────────────────────────── // ── Récapitulatif des imports ──────────────────────────────────
router.get('/organized-summary', requireAuth, async (_req, res) => { router.get('/organized-summary', requireAuth, async (_req, res) => {
try { try {
const { nfoSupport } = readSettings() const { nfoSupport } = readSettings()
@@ -478,8 +473,7 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
let orgEntry: any = null let orgEntry: any = null
let orgHash: string | null = null let orgHash: string | null = null
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord // Chercher d'abord via ep.paths : le hash détermine le formatted_name
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
for (const p of ep.paths ?? []) { for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase() 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 }) res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
} catch (err) { } catch (err) {
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`) 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 : 'Erreur inconnue' }) 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) => { 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 only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
const { series, complete } = await loadCatalogStatus() 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 let removed = 0
try { try {
updateOrganized(data => { updateOrganized(data => {
@@ -529,19 +523,19 @@ router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
return removed > 0 return removed > 0
}) })
} catch (err) { } 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 }) res.json({ ok: true, removed })
}) })
// ── Dossier série ────────────────────────────────────────────── // ── Dossier de série ───────────────────────────────────────────
router.get('/organized-folders', requireAdmin, async (_req, res) => { router.get('/organized-folders', requireAdmin, async (_req, res) => {
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
if (!mediaPath) { res.json([]); return } if (!mediaPath) { res.json([]); return }
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 { try {
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = [] const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
const catalog = await loadCatalog() const catalog = await loadCatalog()
@@ -553,8 +547,8 @@ router.get('/organized-folders', requireAdmin, async (_req, res) => {
} }
res.json(result) res.json(result)
} catch (err) { } catch (err) {
logger.error('api', `organized-folders échoué : ${err instanceof Error ? err.message : err}`) logger.error('api', `Échec de la vérification des dossiers de séries : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) 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() const { mediaPath } = readSettings()
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
const others = otherSerieFolders(await loadCatalog().catch(() => []), serieId, mediaPath) 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) })) 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) => { router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
const serieId = Number(req.params.serieId) 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 } if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
const { mediaPath } = readSettings() 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 let organized: Organized
try { organized = readOrganized() } 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[] let catalog: any[]
try { catalog = await loadCatalog() } try { catalog = await loadCatalog() }
catch (err) { res.status(503).json({ error: `Catalogue indisponible : ${err instanceof Error ? err.message : err}` }); return } 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 targets = [...moves.values()]
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i) const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
if (conflicts.length > 0) { 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>() const done = new Map<string, string>()
@@ -616,19 +610,19 @@ router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
let updated = 0 let updated = 0
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) } try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
catch (err) { 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}`) 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 un scan de la médiathèque' }); return 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) { if (failure) {
logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`) logger.error('api', `Renommage du 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 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 }) res.json({ ok: true, moved: done.size, updated })
}) })
// ── Rename en masse ──────────────────────────────────────────── // ── Renommage groupé ───────────────────────────────────────────
router.post('/rename-all', requireAdmin, async (req, res) => { router.post('/rename-all', requireAdmin, async (req, res) => {
const { serie_id, serie_ids } = req.body 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 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() const seriesData = await loadCatalog()
let organized: Organized let organized: Organized
try { organized = readOrganized() } 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 done: number[] = []
const errors: { episode_id: number; error: string }[] = [] const errors: { episode_id: number; error: string }[] = []
for (const sd of seriesData) { 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) const newPath = path.join(path.dirname(oldPath), expectedName)
try { try {
if (!fs.existsSync(oldPath)) { if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"` const msg = `Fichier introuvable : « ${oldPath} »`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`) logger.error('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue errors.push({ episode_id: ep.id, error: msg }); continue
} }
if (fs.existsSync(newPath)) { if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"` const msg = `Un fichier nommé « ${expectedName} » existe déjà`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`) logger.warn('api', `Renommage groupé, épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue errors.push({ episode_id: ep.id, error: msg }); continue
} }
fs.renameSync(oldPath, newPath) fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() } organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
retargetEntries(organized, new Map([[oldPath, newPath]])) retargetEntries(organized, new Map([[oldPath, newPath]]))
done.push(ep.id) done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`) logger.info('api', `Renommage : « ${currentName} » en « ${expectedName} »`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : UNEXPECTED_ERROR
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`) logger.error('api', `Échec du renommage de l'épisode ${ep.id} : ${msg}`)
errors.push({ episode_id: ep.id, error: 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 }) 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) => { router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) { 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']) const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
@@ -703,10 +697,10 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
try { try {
fs.unlinkSync(full) fs.unlinkSync(full)
deleted++ deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`) logger.debug('api', `Purge NFO : « ${full} » supprimé`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`) logger.error('api', `Purge NFO : impossible de supprimer « ${full} » : ${msg}`)
errors.push(full) errors.push(full)
} }
} }
@@ -714,7 +708,7 @@ router.post('/purge-nfo', requireAuth, (req, res) => {
} }
walk(mediaPath) 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 }) res.json({ ok: true, deleted, errors })
}) })
+7 -7
View File
@@ -25,7 +25,7 @@ router.post('/invites', requireAdmin, (req, res) => {
}) })
res.json(invite) res.json(invite)
} catch (err) { } 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)) deleteInvite(String(req.params.code))
res.json({ success: true }) res.json({ success: true })
} catch (err) { } 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) => { router.get('/invites/:code/info', (req, res) => {
const result = validateInvite(req.params.code) const result = validateInvite(req.params.code)
if (!result.valid) { if (!result.valid) {
@@ -48,7 +48,7 @@ router.get('/invites/:code/info', (req, res) => {
res.json({ valid: true, note: result.invite.note ?? null }) 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) => { router.post('/invites/:code/register', (req, res) => {
const result = validateInvite(req.params.code) const result = validateInvite(req.params.code)
if (!result.valid) { if (!result.valid) {
@@ -57,10 +57,10 @@ router.post('/invites/:code/register', (req, res) => {
const { username, password } = req.body const { username, password } = req.body
if (!username || !password) { 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) { 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 { try {
@@ -68,7 +68,7 @@ router.post('/invites/:code/register', (req, res) => {
consumeInvite(req.params.code) consumeInvite(req.params.code)
res.json({ success: true, user: safeUser(user) }) res.json({ success: true, user: safeUser(user) })
} catch (err) { } 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() const router = Router()
// GET /api/jellyfin/settings
router.get('/jellyfin/settings', (_req, res) => { router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings() const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({ res.json({
jellyfinUrl, 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, hasToken: !!jellyfinAdminToken,
}) })
}) })
// POST /api/jellyfin/settings
router.post('/jellyfin/settings', (req, res) => { router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record<string, string> = {} const update: Record<string, string> = {}
@@ -26,11 +24,10 @@ router.post('/jellyfin/settings', (req, res) => {
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken }) res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
}) })
// POST /api/jellyfin/test
router.post('/jellyfin/test', async (_req, res) => { router.post('/jellyfin/test', async (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings() const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) { 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) const result = await testJellyfinConnection(jellyfinUrl, jellyfinAdminToken)
res.json(result) res.json(result)
@@ -57,20 +54,19 @@ export async function runJellyfinSync(): Promise<{ created: number; skipped: num
createUser(jUser.Name, randomPass, 'user') createUser(jUser.Name, randomPass, 'user')
results.created++ results.created++
results.users.push(jUser.Name) 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 return results
} }
// POST /api/jellyfin/sync
router.post('/jellyfin/sync', async (_req, res) => { router.post('/jellyfin/sync', async (_req, res) => {
try { try {
const results = await runJellyfinSync() const results = await runJellyfinSync()
res.json(results) res.json(results)
} catch (err) { } 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) => { router.post('/nfo-updates/check', requireAuth, async (_req, res) => {
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée') logger.info('nfo-update', 'Vérification manuelle des mises à jour NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`)) 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' }) 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) const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
} catch (err) { } catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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) => { router.post('/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body 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 { try {
const seriesData = await loadCatalog() const seriesData = await loadCatalog()
// Récupère la progression par fichier depuis le client torrent. // Progression par fichier, facultative (client injoignable toléré)
// 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.
const files = await dispatchGetFiles(hash).catch(() => []) const files = await dispatchGetFiles(hash).catch(() => [])
const result = await organizeTorrent(hash, name, save_path, seriesData, files) const result = await organizeTorrent(hash, name, save_path, seriesData, files)
@@ -55,8 +53,8 @@ router.post('/organize', requireAuth, async (req, res) => {
} }
res.json(result) res.json(result)
} catch (err) { } catch (err) {
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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' 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) { function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) => return (url: string, opts: any = {}) =>
undiciFetch(url, { 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> { async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, { const res = await fetch(url, {
...options, ...options,
@@ -28,7 +27,10 @@ async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
...(options.headers ?? {}), ...(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() const text = await res.text()
return text ? JSON.parse(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) => { router.post('/plex/connect', requireAuth, async (req, res) => {
const { username, password, code } = req.body const { username, password, code } = req.body
if (!username || !password) { res.status(400).json({ error: 'Email et mot de passe requis' }); return } 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(), body : params.toString(),
}) })
const token = authData.authToken ?? authData.user?.authToken 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 resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources) 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 }) res.json({ ok: true, token, servers })
} catch (err) { } 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')) { 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 { } else {
logger.error('plex', `Auth échouée : ${msg}`) logger.error('plex', `Échec de l'authentification Plex : ${msg}`)
res.status(401).json({ error: 'Authentification échouée — vérifiez vos identifiants' }) 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) => { router.post('/plex/oauth/start', requireAuth, async (_req, res) => {
try { try {
const data = await plexFetch(`${PLEX_TV_API}/pins`, { 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 pinId = data.id
const pinCode = data.code 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` 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 }) res.json({ ok: true, pinId, pinCode, authUrl })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('plex', `OAuth start échoué : ${msg}`) logger.error('plex', `Échec du démarrage de la connexion Plex : ${msg}`)
res.status(500).json({ error: 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) => { router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
const pinId = String(req.params.pinId) const pinId = String(req.params.pinId)
try { try {
@@ -102,22 +101,20 @@ router.get('/plex/oauth/poll/:pinId', requireAuth, async (req, res) => {
if (!token) { res.json({ ok: false, pending: true }); return } 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 resources = await plexFetch('https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1&includeIPv6=1', { headers: { 'X-Plex-Token': token } })
const servers = mapServers(resources) 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 }) res.json({ ok: true, pending: false, token, servers })
} catch (err) { } 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 }) 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) => { router.post('/plex/setup', requireAuth, async (req, res) => {
const { token, serverUrl, libraryName, libraryPath } = req.body const { token, serverUrl, libraryName, libraryPath } = req.body
if (!token || !serverUrl || !libraryName || !libraryPath) { 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 serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' } 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 groupId: string | null = null
let agentSetupOk = false let agentSetupOk = false
// ── Étape 1 : Enregistrer le provider Fankai ───────────── // ── Étape 1 : enregistrer l'agent Fankai ─────────────────
try { try {
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers }) const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) { if (providersRes.ok) {
@@ -140,7 +137,7 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
} }
if (provider) { if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier 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 }) const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) { if (groupsRes.ok) {
@@ -156,22 +153,22 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
} }
if (group) { if (group) {
groupId = String(group.id ?? '') 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 agentSetupOk = true
} }
} }
} else { } else {
const status = providersRes.status const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` }) 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', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`) logger.warn('plex', `Configuration de l'agent Fankai : HTTP ${status} depuis ${serverUrl}`)
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` }) steps.push({ step: 'agent', ok: false, message: `Agent Fankai non configuré : configuration manuelle nécessaire` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`) 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 { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
type : 'show', type : 'show',
@@ -183,14 +180,17 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}) })
if (groupId) params.set('metadataAgentProviderGroupId', groupId) if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers }) const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`) if (!libRes.ok) {
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` }) logger.debug('plex', `Création de bibliothèque HTTP ${libRes.status} : ${await libRes.text().catch(() => '')}`)
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`) 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 }) res.json({ ok: true, agentSetupOk, steps, manualSetup: !agentSetupOk })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
steps.push({ step: 'library', ok: false, message: `Création bibliothèque échouée : ${msg}` }) steps.push({ step: 'library', ok: false, message: `Échec de la création de la bibliothèque : ${msg}` })
logger.error('plex', `Création bibliothèque échouée : ${msg}`) logger.error('plex', `Échec de la création de la bibliothèque : ${msg}`)
res.status(500).json({ ok: false, steps, error: 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 { Router } from 'express'
import { findByApiToken } from '../users.js' import { findByApiToken } from '../users.js'
@@ -17,24 +13,23 @@ import type { Request, Response, NextFunction } from 'express'
const router = Router() const router = Router()
// ── Middleware auth API token ───────────────────────────────── // ── Jeton d'API ───────────────────────────────────────────────
function requireApiToken(req: Request, res: Response, next: NextFunction): void { function requireApiToken(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) { 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)) const user = findByApiToken(header.slice(7))
if (!user) { if (!user) {
res.status(401).json({ error: 'Token invalide' }); return res.status(401).json({ error: 'Jeton d\'API invalide' }); return
} }
req.user = user req.user = user
next() next()
} }
// ── POST /api/v1/auth/jellyfin ──────────────────────────────── // ── POST /api/v1/auth/jellyfin ────────────────────────────────
// Échange un token Jellyfin contre un token FanKarr // Échange un jeton Jellyfin contre un jeton FanKarr
// Body: { jellyfinUserId, jellyfinToken }
router.post('/v1/auth/jellyfin', async (req, res) => { router.post('/v1/auth/jellyfin', async (req, res) => {
const { jellyfinUserId, jellyfinToken } = req.body const { jellyfinUserId, jellyfinToken } = req.body
if (!jellyfinUserId || !jellyfinToken) { if (!jellyfinUserId || !jellyfinToken) {
@@ -43,20 +38,19 @@ router.post('/v1/auth/jellyfin', async (req, res) => {
const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken) const info = await validateJellyfinToken(jellyfinUserId, jellyfinToken)
if (!info.valid) { 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 users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase()) const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
if (!fanUser) { 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({ 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 }); 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({ res.json({
token : fanUser.apiToken, token : fanUser.apiToken,
username: fanUser.username, username: fanUser.username,
@@ -70,7 +64,6 @@ router.get('/v1/auth/me', requireApiToken, (req, res) => {
}) })
// ── GET /api/v1/series/search?q= ───────────────────────────── // ── GET /api/v1/series/search?q= ─────────────────────────────
// Recherche dans le catalogue FanKai
router.get('/v1/series/search', requireApiToken, async (req, res) => { router.get('/v1/series/search', requireApiToken, async (req, res) => {
const q = String(req.query.q ?? '').trim().toLowerCase() const q = String(req.query.q ?? '').trim().toLowerCase()
try { try {
@@ -113,15 +106,15 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
res.json(results) res.json(results)
} catch (err) { } 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 ──────────────────────────────────── // ── 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) => { router.get('/v1/series/:id', requireApiToken, async (req, res) => {
const id = Number(req.params.id) 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 { try {
const [serieRaw, seasonsData] = await Promise.all([ const [serieRaw, seasonsData] = await Promise.all([
fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}`),
@@ -156,12 +149,11 @@ router.get('/v1/series/:id', requireApiToken, async (req, res) => {
seasons : seasonsWithEpisodes, seasons : seasonsWithEpisodes,
}) })
} catch (err) { } 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 ───────────────────────────────────── // ── POST /api/v1/requests ─────────────────────────────────────
// Créer ou mettre à jour une demande
router.post('/v1/requests', requireApiToken, (req, res) => { router.post('/v1/requests', requireApiToken, (req, res) => {
const { serieId, serieName, seasons, episodes } = req.body const { serieId, serieName, seasons, episodes } = req.body
if (!serieId || !serieName) { if (!serieId || !serieName) {
@@ -179,7 +171,6 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
submittedEpisodes, submittedEpisodes,
) )
// Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings() const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all' const allowed = requestAutoDownloadUsers === 'all'
@@ -190,18 +181,17 @@ router.post('/v1/requests', requireApiToken, (req, res) => {
if (isNew) request = approveRequest(request.id) if (isNew) request = approveRequest(request.id)
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes } const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err => 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) res.json(request)
} catch (err) { } 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 ────────────────────────────────────── // ── GET /api/v1/requests ──────────────────────────────────────
// Demandes de l'utilisateur authentifié
router.get('/v1/requests', requireApiToken, (req, res) => { router.get('/v1/requests', requireApiToken, (req, res) => {
res.json(getRequestsForUser(req.user!.id)) res.json(getRequestsForUser(req.user!.id))
}) })
+16 -31
View File
@@ -14,7 +14,6 @@ import { logger } from '../logger.js'
const router = Router() const router = Router()
// GET /api/requests — admin voit tout, user voit les siennes
router.get('/requests', async (req, res) => { router.get('/requests', async (req, res) => {
const list = req.user!.role === 'admin' const list = req.user!.role === 'admin'
? readRequests() ? 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) => { router.get('/requests/pending-count', (req, res) => {
const count = req.user!.role === 'admin' const count = req.user!.role === 'admin'
? getPendingCount() ? getPendingCount()
@@ -45,11 +43,10 @@ router.get('/requests/pending-count', (req, res) => {
res.json({ count }) res.json({ count })
}) })
// POST /api/requests — créer ou mettre à jour une demande
router.post('/requests', (req, res) => { router.post('/requests', (req, res) => {
const { serieId, serieName, seasons, episodes, torrentOverride } = req.body const { serieId, serieName, seasons, episodes, torrentOverride } = req.body
if (!serieId || !serieName) { 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 { try {
const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : [] const submittedSeasons = Array.isArray(seasons) ? seasons.map(Number) : []
@@ -63,7 +60,6 @@ router.post('/requests', (req, res) => {
submittedEpisodes, submittedEpisodes,
) )
// Auto-approbation + téléchargement si l'utilisateur est autorisé
const { requestAutoDownloadUsers } = readSettings() const { requestAutoDownloadUsers } = readSettings()
const userId = req.user!.id const userId = req.user!.id
const allowed = requestAutoDownloadUsers === 'all' const allowed = requestAutoDownloadUsers === 'all'
@@ -74,7 +70,6 @@ router.post('/requests', (req, res) => {
if (isNew) request = approveRequest(request.id) if (isNew) request = approveRequest(request.id)
if (torrentOverride?.torrent_url || torrentOverride?.magnet) { 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 const url = torrentOverride.torrent_url ?? torrentOverride.magnet
dispatchDownload(url, { dispatchDownload(url, {
infohash : torrentOverride.infohash ?? null, infohash : torrentOverride.infohash ?? null,
@@ -82,24 +77,22 @@ router.post('/requests', (req, res) => {
file_index: torrentOverride.file_index ?? null, file_index: torrentOverride.file_index ?? null,
file_path : torrentOverride.file_path ?? null, file_path : torrentOverride.file_path ?? null,
}).catch(err => }).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 { } else {
// Pas de torrent sélectionné → résolution automatique
const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes } const dlOverride = isNew ? undefined : { seasons: submittedSeasons, episodes: submittedEpisodes }
autoDownloadRequest(request, dlOverride).catch(err => 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) res.json(request)
} catch (err) { } 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) => { router.patch('/requests/:id', requireAdmin, async (req, res) => {
const { action, rejectionMessage } = req.body const { action, rejectionMessage } = req.body
try { try {
@@ -107,28 +100,23 @@ router.patch('/requests/:id', requireAdmin, async (req, res) => {
const id = String(req.params.id) const id = String(req.params.id)
if (action === 'approve') { if (action === 'approve') {
request = approveRequest(id) request = approveRequest(id)
// Approbation manuelle par l'admin → toujours lancer le téléchargement
autoDownloadRequest(request).catch(err => 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 === 'reject') request = rejectRequest(id, rejectionMessage)
else if (action === 'complete') request = completeRequest(id) 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) res.json(request)
} catch (err) { } 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' })
} }
}) })
/** /** Envoie les torrents de la demande ; override limite aux saisons et épisodes qui viennent d'être ajoutés. */
* 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é)
*/
export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> { export async function autoDownloadRequest(req: SerieRequest, override?: { seasons: number[]; episodes: number[] }): Promise<void> {
const serieData = await readSerieData(req.serieId) const serieData = await readSerieData(req.serieId)
if (!serieData) { 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 return
} }
@@ -138,7 +126,7 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const toDownload = resolveRequestTorrents(serieData, seasons, episodes) const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
if (toDownload.length === 0) { 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 return
} }
@@ -147,16 +135,15 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
const url = t.url ?? t.magnet const url = t.url ?? t.magnet
if (!url) continue if (!url) continue
try { try {
await dispatchDownload(url, { infohash: t.infohash ?? undefined, magnet: t.magnet ?? undefined, file_index: t.file_index ?? null, file_path: t.file_path ?? null }) 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 })
sent++ if (results.some(r => r.ok)) sent++
} catch (err) { } 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) => { router.delete('/requests', requireAdmin, (_req, res) => {
const all = readRequests() const all = readRequests()
for (const r of all) { for (const r of all) {
@@ -166,22 +153,20 @@ router.delete('/requests', requireAdmin, (_req, res) => {
res.json({ deleted: all.length }) 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) => { router.delete('/requests/:id/mine', (req, res) => {
try { try {
res.json(withdrawRequest(String(req.params.id), req.user!.id)) res.json(withdrawRequest(String(req.params.id), req.user!.id))
} catch (err) { } 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) => { router.delete('/requests/:id', requireAdmin, (req, res) => {
try { try {
deleteRequest(String(req.params.id)) deleteRequest(String(req.params.id))
res.json({ success: true }) res.json({ success: true })
} catch (err) { } 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() const router = Router()
/** GET /api/rss-sync → liste des séries surveillées */
router.get('/rss-sync', requireAuth, (_req, res) => { router.get('/rss-sync', requireAuth, (_req, res) => {
res.json(Object.values(loadSynced())) 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) => { router.post('/rss-sync/run', requireAuth, async (_req, res) => {
try { try {
const result = await runRssSync() const result = await runRssSync()
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
} catch (err) { } catch (err) {
logger.error('rss-sync', `Sync forcé échoué : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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) => { router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
const { series, complete } = await loadCatalogStatus() const { series, complete } = await loadCatalogStatus()
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] }) 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) => { router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
const enabled = req.body?.enabled === true const enabled = req.body?.enabled === true
const series = (Array.isArray(req.body?.series) ? req.body.series : []) const series = (Array.isArray(req.body?.series) ? req.body.series : [])
.map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') })) .map((s: any) => ({ id: Number(s?.id), name: String(s?.name ?? '') }))
.filter((s: { id: number; name: string }) => s.id && 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) 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 }) 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) => { router.get('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) const serieId = Number(req.params.id)
res.json({ synced: isSynced(serieId) }) res.json({ synced: isSynced(serieId) })
}) })
/** POST /api/rss-sync/:id → activer la surveillance */
router.post('/rss-sync/:id', requireAuth, (req, res) => { router.post('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) const serieId = Number(req.params.id)
const serieName = String(req.body.serieName ?? '') 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) 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 }) 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) => { router.delete('/rss-sync/:id', requireAuth, (req, res) => {
const serieId = Number(req.params.id) const serieId = Number(req.params.id)
const map = setSync(serieId, '', false) const map = setSync(serieId, '', false)
+5 -11
View File
@@ -17,7 +17,6 @@ import { recentImportsBySerie } from '../lib/notifs.js'
const router = Router() const router = Router()
// Séries récemment ajoutées à la médiathèque
router.get('/library/recent', requireAuth, (req, res) => { router.get('/library/recent', requireAuth, (req, res) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50) const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
res.json(recentImportsBySerie().slice(0, limit)) res.json(recentImportsBySerie().slice(0, limit))
@@ -52,8 +51,8 @@ router.get('/series', requireAuth, async (_req, res) => {
} }
})}) })})
} catch (err) { } catch (err) {
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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) { 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 }>() 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 ?? [])) 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 }) 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) 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) { if (!episodeTorrentMap[ep.id] || episodeTorrentMap[ep.id].length === 0) {
const seenPackHashes = new Set<string>() const seenPackHashes = new Set<string>()
for (const pathEntry of (ep.paths ?? [])) { for (const pathEntry of (ep.paths ?? [])) {
@@ -175,8 +171,6 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organized: organizedEpisodeIds.has(ep.id), 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 eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).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 })) }) 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) { } catch (err) {
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`) 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 inconnue' }) 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() 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) => { router.get('/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100 const limit = Number(req.query.limit) || 100
const level = (req.query.level as string) || 'all' 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) => { router.post('/logs/clear', requireAuth, (_req, res) => {
clearLogs() clearLogs()
logger.info('api', 'Logs effacés') logger.info('api', 'Journaux effacés')
res.json({ ok: true }) res.json({ ok: true })
}) })
@@ -39,7 +49,7 @@ router.get('/system', requireAuth, (_req, res) => {
router.post('/system/check-paths', requireAuth, (req, res) => { router.post('/system/check-paths', requireAuth, (req, res) => {
const { mediaPath, completePath } = req.body ?? {} const { mediaPath, completePath } = req.body ?? {}
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) { 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)) 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) => { router.get('/browse', requireAuth, (req, res) => {
const isWindows = process.platform === 'win32' const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/' 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 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)) 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 let parent: string | null
if (dirPath === '/') { if (dirPath === '/') {
parent = null parent = null
@@ -82,13 +92,13 @@ router.get('/browse', requireAuth, (req, res) => {
parent = '/' parent = '/'
} else { } else {
const up = path.dirname(dirPath) 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 }) res.json({ path: dirPath, parent, dirs })
} catch (err) { } catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) 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')) files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files, exists: true }) res.json({ path: dirPath, files, exists: true })
} catch (err) { } catch (err) {
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`) logger.warn('api', `Lecture du dossier « ${dirPath} » impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) res.status(400).json({ error: readDirError(dirPath, err) })
} }
}) })
@@ -139,19 +149,19 @@ router.post('/update', requireAuth, async (req, res) => {
try { try {
cacheClear() cacheClear()
logger.info('api', force logger.info('api', force
? 'Forçage des correspondances de renommage — cache mémoire et CDN contournés' ? 'Resynchronisation complète du catalogue (caches ignorés), noms de fichiers recalculés'
: 'Cache GitHub vidé — rechargement forcé') : 'Cache GitHub vidé : rechargement du catalogue')
const availableIds = await readAvailable(force) const availableIds = await readAvailable(force)
if (!Array.isArray(availableIds)) throw new Error('available.json invalide') if (!Array.isArray(availableIds)) throw new Error('Catalogue GitHub invalide (available.json)')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`) logger.info('api', `Catalogue rechargé : ${availableIds.length} séries disponibles`)
if (force) { if (force) {
const seriesData = await loadCatalog(true) const seriesData = await loadCatalog(true)
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData) const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
const { removed } = dedupeOrganizedEpisodes(organizedPath) const { removed } = dedupeOrganizedEpisodes(organizedPath)
const { renamed, errors } = await syncFilenameChanges(seriesData, 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 }) res.json({ ok: true, count: availableIds.length, migrated: updated, deduped: removed, renamed, errors })
return return
} }
@@ -162,14 +172,14 @@ router.post('/update', requireAuth, async (req, res) => {
const seriesData = await loadCatalog() const seriesData = await loadCatalog()
const { renamed } = await syncFilenameChanges(seriesData, organizedPath) const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0) 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) { } 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) { } catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`) 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 : 'Erreur inconnue' }) 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) => { router.post('/scan', requireAuth, async (_req, res) => {
try { try {
const { mediaPath } = readSettings() 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 seriesData = await loadCatalog()
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData) const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
} catch (err) { } catch (err) {
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`) 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 : 'Erreur inconnue' }) res.status(500).json({ error: err instanceof Error ? err.message : UNEXPECTED_ERROR })
} }
}) })
// ── Debug ────────────────────────────────────────────────────── // ── Diagnostic ─────────────────────────────────────────────────
router.get('/debug/stats', requireAuth, (req, res) => { router.get('/debug/stats', requireAuth, (req, res) => {
const mem = process.memoryUsage() const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime()) 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) => { router.post('/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); 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 inconnu : ${type}` }); return } if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
res.json(sanitizeClient(addClient(name, type, config))) 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) => { router.put('/torrent-clients/:uuid', requireAuth, (req, res) => {
const { name, type, config } = req.body const { name, type, config } = req.body
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); 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 inconnu : ${type}` }); return } if (!getDriver(type)) { res.status(400).json({ error: `Type de client inconnu : ${type}` }); return }
const oldClient = getClient(String(req.params.uuid)) const oldClient = getClient(String(req.params.uuid))
if (oldClient) { if (oldClient) {
const driver = getDriver(type) 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) => { router.post('/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config, uuid } = req.body 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) 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) { if (uuid) {
const oldClient = getClient(uuid) const oldClient = getClient(uuid)
if (oldClient) { if (oldClient) {
@@ -71,7 +71,7 @@ router.post('/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
const client = getClient(String(req.params.uuid)) const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return } if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type) 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)) 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)) const client = getClient(String(req.params.uuid))
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return } if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
const driver = getDriver(client.type) 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)) res.json(await driver.healthcheck(client.config))
}) })
+7 -13
View File
@@ -6,60 +6,54 @@ import {
const router = Router() const router = Router()
// GET /api/users
router.get('/users', (_req, res) => { router.get('/users', (_req, res) => {
res.json(readUsers().map(safeUser)) res.json(readUsers().map(safeUser))
}) })
// POST /api/users
router.post('/users', (req, res) => { router.post('/users', (req, res) => {
const { username, password, role } = req.body const { username, password, role } = req.body
if (!username || !password) { 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) { 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 { try {
const user = createUser(username, password, role === 'admin' ? 'admin' : 'user') const user = createUser(username, password, role === 'admin' ? 'admin' : 'user')
res.json(safeUser(user)) res.json(safeUser(user))
} catch (err) { } 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) => { router.patch('/users/:id', (req, res) => {
const { username, password, role } = req.body const { username, password, role } = req.body
try { try {
const user = updateUser(req.params.id, { username, password, role }) const user = updateUser(req.params.id, { username, password, role })
res.json(safeUser(user)) res.json(safeUser(user))
} catch (err) { } 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) => { router.delete('/users/:id', (req, res) => {
// Empêche l'admin de se supprimer lui-même
if (req.user?.id === req.params.id) { 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 { try {
deleteUser(req.params.id) deleteUser(req.params.id)
res.json({ success: true }) res.json({ success: true })
} catch (err) { } 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) => { router.post('/users/:id/regenerate-token', (req, res) => {
try { try {
const token = regenerateApiToken(req.params.id) const token = regenerateApiToken(req.params.id)
res.json({ apiToken: token }) res.json({ apiToken: token })
} catch (err) { } 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 fs from 'fs'
import path from 'path' import path from 'path'
@@ -18,25 +11,22 @@ function generateSecret(): string {
} }
function loadOrCreateSecret(): string { function loadOrCreateSecret(): string {
// 1. Variable d'environnement — priorité absolue
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') { if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
return process.env.JWT_SECRET return process.env.JWT_SECRET
} }
// 2. Fichier existant
if (fs.existsSync(SECRET_PATH)) { if (fs.existsSync(SECRET_PATH)) {
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim() const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
if (secret.length > 0) return secret if (secret.length > 0) return secret
} }
// 3. Génération + sauvegarde
const secret = generateSecret() const secret = generateSecret()
try { try {
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true }) fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 }) 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) { } catch (err) {
console.error('[auth] Impossible de sauvegarder le secret:', err) console.error('[auth] Impossible d\'enregistrer le secret JWT :', err)
} }
return secret return secret
+4 -4
View File
@@ -13,10 +13,10 @@ export interface Settings {
autoImport : boolean autoImport : boolean
devMode : boolean devMode : boolean
deleteTorrentOnMove : boolean deleteTorrentOnMove : boolean
requestAutoDownloadUsers: 'all' | string[] // IDs users autorisés au dl auto sur approbation ; [] = désactivé 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) jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1") englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null 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 } const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
if (!raw) return { ...result, error: 'Chemin vide' } 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' } if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
result.resolved = path.resolve(raw) result.resolved = path.resolve(raw)
@@ -72,7 +72,9 @@ export function checkDirectory(input: string, { allowRoot = true } = {}): PathCh
result.writable = true result.writable = true
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code 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 { } finally {
removeQuietly(probe) removeQuietly(probe)
} }
@@ -134,7 +136,7 @@ export function checkPaths(mediaPath: string, completePath?: string) {
let hardlink: HardlinkCheck let hardlink: HardlinkCheck
if (!hasComplete) { 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)) { } else if (!valid(media) || !valid(complete)) {
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' } hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
} else if (relation === 'same') { } else if (relation === 'same') {
+28 -59
View File
@@ -1,20 +1,10 @@
/** /** Deluge via l'API JSON-RPC de l'interface web. Progression en pourcentage (0–100). */
* 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)
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js' import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js' import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── Session cache ───────────────────────────────────────────────────────────── // ─── Sessions ──────────────────────────────────────────────────────────────────
// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
interface SessionEntry { cookie: string; expires: number } interface SessionEntry { cookie: string; expires: number }
const _sessions = new Map<string, SessionEntry>() const _sessions = new Map<string, SessionEntry>()
@@ -23,14 +13,14 @@ function sessionKey(config: ClientConfig): string {
return `${config.url}::${config.password}` return `${config.url}::${config.password}`
} }
// ─── Helpers HTTP ────────────────────────────────────────────────────────────── // ─── HTTP ──────────────────────────────────────────────────────────────────────
function delugeUrl(config: ClientConfig): string { function delugeUrl(config: ClientConfig): string {
return String(config.url ?? '').replace(/\/+$/, '') + '/json' return String(config.url ?? '').replace(/\/+$/, '') + '/json'
} }
function parseCookie(raw: string): string { 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() return raw.split(';')[0].trim()
} }
@@ -51,14 +41,11 @@ async function delugeRPC(
body: JSON.stringify({ method, params, id: 1 }), body: JSON.stringify({ method, params, id: 1 }),
}) })
if (!res.ok) { if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const text = await res.text().catch(() => '')
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
}
const json = await res.json() const json = await res.json()
if (json.error) { 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') const setCookie = res.headers.get('set-cookie')
@@ -68,7 +55,7 @@ async function delugeRPC(
} }
} }
// ─── Login ───────────────────────────────────────────────────────────────────── // ─── Connexion ─────────────────────────────────────────────────────────────────
async function delugeLogin(config: ClientConfig): Promise<string> { async function delugeLogin(config: ClientConfig): Promise<string> {
const url = delugeUrl(config) 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 }), body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
}) })
if (!res.ok) { if (!res.ok) throw new Error(`Deluge a répondu HTTP ${res.status} : vérifiez l'URL de l'interface web`)
const text = await res.text().catch(() => '')
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
}
const json = await res.json() const json = await res.json()
if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`) if (json.error) throw new Error(`Connexion à Deluge refusée : ${json.error.message ?? `code ${json.error.code ?? 'inconnu'}`}`)
if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)') if (!json.result) throw new Error('Connexion à Deluge refusée : mot de passe incorrect ?')
const setCookie = res.headers.get('set-cookie') 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) return parseCookie(setCookie)
} }
// ─── Call avec gestion de session ───────────────────────────────────────────── // ─── Appel avec gestion de session ────────────────────────────────────────────
async function delugeCall( async function delugeCall(
config : ClientConfig, config : ClientConfig,
@@ -103,7 +87,6 @@ async function delugeCall(
const url = delugeUrl(config) const url = delugeUrl(config)
const key = sessionKey(config) const key = sessionKey(config)
// Récupère ou rafraîchit la session
let entry = _sessions.get(key) let entry = _sessions.get(key)
if (!entry || Date.now() >= entry.expires) { if (!entry || Date.now() >= entry.expires) {
const cookie = await delugeLogin(config) const cookie = await delugeLogin(config)
@@ -113,7 +96,6 @@ async function delugeCall(
try { try {
const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie) const { result, newCookie } = await delugeRPC(config, url, method, params, entry.cookie)
// Mise à jour du cookie si Deluge en renvoie un nouveau
if (newCookie) { if (newCookie) {
entry.cookie = newCookie entry.cookie = newCookie
entry.expires = Date.now() + 30 * 60_000 entry.expires = Date.now() + 30 * 60_000
@@ -121,7 +103,6 @@ async function delugeCall(
return result return result
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : '' const msg = err instanceof Error ? err.message : ''
// Session expirée → relogin unique
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) { if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
_sessions.delete(key) _sessions.delete(key)
const cookie = await delugeLogin(config) const cookie = await delugeLogin(config)
@@ -134,7 +115,7 @@ async function delugeCall(
} }
} }
// ─── Mapping état ────────────────────────────────────────────────────────────── // ─── États ─────────────────────────────────────────────────────────────────────
function mapState(state: string): TorrentInfo['state'] { function mapState(state: string): TorrentInfo['state'] {
switch (state) { switch (state) {
@@ -175,9 +156,7 @@ function flattenDelugeFiles(
return result return result
} }
// ─── Sélection de fichier (asynchrone, best-effort) ────────────────────────── // ─── Sélection de fichier (en arrière-plan) ──────────────────────────────────
// 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).
async function applyFilePriority( async function applyFilePriority(
config : ClientConfig, config : ClientConfig,
@@ -193,14 +172,14 @@ async function applyFilePriority(
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0) const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }]) 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 return
} catch {} } 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 = { const delugeDriver: TorrentClientDriver = {
definition: { definition: {
@@ -209,15 +188,14 @@ const delugeDriver: TorrentClientDriver = {
fields: [ fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true }, { key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', 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: 'category', label: 'Catégorie (plugin Label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false }, { key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
// ── Test ───────────────────────────────────────────────────────────────────
async test(config) { async test(config) {
try { try {
_sessions.delete(sessionKey(config)) _sessions.delete(sessionKey(config))
@@ -226,16 +204,14 @@ const delugeDriver: TorrentClientDriver = {
logger.info('deluge', `Test de connexion réussi sur ${config.url}`) logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('deluge', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
// ── Healthcheck ────────────────────────────────────────────────────────────
async healthcheck(config) { async healthcheck(config) {
try { try {
// daemon.get_version() retourne la version du daemon Deluge
const version = await delugeCall(config, 'daemon.get_version') const version = await delugeCall(config, 'daemon.get_version')
return { online: true, version: String(version ?? 'inconnue') } return { online: true, version: String(version ?? 'inconnue') }
} catch { } catch {
@@ -243,15 +219,13 @@ const delugeDriver: TorrentClientDriver = {
} }
}, },
// ── List ───────────────────────────────────────────────────────────────────
async list(config, category?) { async list(config, category?) {
const keys = [ const keys = [
'name', 'state', 'progress', 'total_size', 'total_done', 'name', 'state', 'progress', 'total_size', 'total_done',
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate', 'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
'eta', 'save_path', 'label', 'eta', 'save_path', 'label',
] ]
// filter_dict vide → tous les torrents ; le filtrage par label se fait en JS // Tous les torrents, filtrés par label ici pour ne pas dépendre du plugin Label
// pour ne pas dépendre du Label plugin côté serveur.
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {} const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
return Object.entries(raw) return Object.entries(raw)
@@ -273,7 +247,6 @@ const delugeDriver: TorrentClientDriver = {
})) }))
}, },
// ── Add ────────────────────────────────────────────────────────────────────
async add(config, url, options?: DownloadOptions) { async add(config, url, options?: DownloadOptions) {
const addOpts: Record<string, unknown> = {} const addOpts: Record<string, unknown> = {}
if (config.savePath) addOpts['download_location'] = String(config.savePath) if (config.savePath) addOpts['download_location'] = String(config.savePath)
@@ -282,36 +255,32 @@ const delugeDriver: TorrentClientDriver = {
if (url.startsWith('magnet:')) { if (url.startsWith('magnet:')) {
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts]) 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 { } else {
// Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
const torrentRes = await fetch(url) 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 buf = await torrentRes.arrayBuffer()
const b64 = Buffer.from(buf).toString('base64') const b64 = Buffer.from(buf).toString('base64')
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent' const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts]) 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) { if (config.category && hash) {
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {}) delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
} }
// Sélection de fichier — asynchrone, best-effort
if (options?.file_index != null && hash) { if (options?.file_index != null && hash) {
applyFilePriority(config, hash, options.file_index).catch(() => {}) applyFilePriority(config, hash, options.file_index).catch(() => {})
} }
}, },
// ── Remove ─────────────────────────────────────────────────────────────────
async remove(config, hash, deleteFiles = false) { async remove(config, hash, deleteFiles = false) {
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles]) await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`) logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
}, },
// ── Get files ──────────────────────────────────────────────────────────────
async getFiles(config, hash) { async getFiles(config, hash) {
const data = await delugeCall(config, 'web.get_torrent_files', [hash]) const data = await delugeCall(config, 'web.get_torrent_files', [hash])
if (!data?.contents) return [] 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 { randomUUID } from 'crypto'
import fs from 'fs' import fs from 'fs'
@@ -29,8 +24,7 @@ export interface TorrentClientDefinition {
id : string id : string
label : string label : string
fields : FieldDef[] fields : FieldDef[]
/** Si true, dispatchList filtre les résultats pour n'inclure que les /** Clients sans catégories : dispatchList ne garde que les torrents ajoutés par FanKarr. */
* torrents ajoutés via FanKarr (clients sans catégories natives). */
filterByManagedHashes?: boolean filterByManagedHashes?: boolean
} }
@@ -43,9 +37,9 @@ export interface SavedClient {
export interface TorrentFileProgress { export interface TorrentFileProgress {
index : number 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 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 { export interface TorrentInfo {
@@ -68,8 +62,8 @@ export interface TorrentInfo {
export interface DownloadOptions { export interface DownloadOptions {
file_index?: number | null file_index?: number | null
file_path ?: string | null file_path ?: string | null
infohash ?: string | null // hash direct depuis les données API (plus fiable que parser le magnet) infohash ?: string | null // hash issu du catalogue (plus fiable que le magnet)
magnet ?: string | null // fallback pour extraire le hash si infohash absent magnet ?: string | null
} }
export interface TorrentClientDriver { export interface TorrentClientDriver {
@@ -83,9 +77,7 @@ export interface TorrentClientDriver {
} }
// ─── Certificats TLS ────────────────────────────────────────────────────────── // ─── Certificats TLS ──────────────────────────────────────────────────────────
// Option "ignoreCertificateErrors" (par client) : accepte les certificats // ignoreCertificateErrors : Agent undici sous Node, option tls du fetch sous Bun
// 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é).
let _insecureAgent: Agent | null = null let _insecureAgent: Agent | null = null
@@ -99,14 +91,13 @@ export function ignoreCertificateErrors(config: ClientConfig): boolean {
return v === true || v === 'true' || v === 1 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 { interface BunRequestInit extends RequestInit {
tls?: { rejectUnauthorized?: boolean } tls?: { rejectUnauthorized?: boolean }
} }
const isBun = 'bun' in process.versions const isBun = 'bun' in process.versions
/** fetch du client : accepte les certificats invalides si l'option est active. */
export function clientFetch( export function clientFetch(
config : ClientConfig, config : ClientConfig,
url : string, 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> return undiciFetch(url, { ...init, dispatcher: insecureAgent() } as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>
} }
// ─── Registry ───────────────────────────────────────────────────────────────── // ─── Registre ─────────────────────────────────────────────────────────────────
const drivers = new Map<string, TorrentClientDriver>() const drivers = new Map<string, TorrentClientDriver>()
export function registerDriver(driver: TorrentClientDriver) { export function registerDriver(driver: TorrentClientDriver) {
drivers.set(driver.definition.id, driver) 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 { export function getDriver(type: string): TorrentClientDriver | undefined {
@@ -137,15 +128,12 @@ export function getAvailableClients(): TorrentClientDefinition[] {
return [...drivers.values()].map(d => d.definition) return [...drivers.values()].map(d => d.definition)
} }
// ─── Persistence ────────────────────────────────────────────────────────────── // ─── Stockage ─────────────────────────────────────────────────────────────────
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json') const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
// ─── Managed tasks ───────────────────────────────────────────────────────────── // ─── Torrents suivis ──────────────────────────────────────────────────────────
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client. // Hashes ajoutés par FanKarr, par client : filtre les clients sans catégories (Synology)
// Utilisé par dispatchList pour filtrer les résultats des clients sans
// catégories natives (ex: Synology Download Station).
// Structure : { clientUuid: [infohash, ...] }
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json') const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
@@ -163,14 +151,12 @@ function saveManagedMap(data: Record<string, string[]>) {
} catch {} } catch {}
} }
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
function getManagedSet(clientUuid: string): Set<string> | null { function getManagedSet(clientUuid: string): Set<string> | null {
const list = loadManagedMap()[clientUuid] const list = loadManagedMap()[clientUuid]
if (!list || list.length === 0) return null if (!list || list.length === 0) return null
return new Set(list) return new Set(list)
} }
/** Enregistre un infohash comme géré pour un client (idempotent). */
export function trackManagedHash(clientUuid: string, infohash: string) { export function trackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap() const data = loadManagedMap()
if (!data[clientUuid]) data[clientUuid] = [] 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) { export function untrackManagedHash(clientUuid: string, infohash: string) {
const data = loadManagedMap() const data = loadManagedMap()
if (!data[clientUuid]) return if (!data[clientUuid]) return
@@ -190,7 +175,6 @@ export function untrackManagedHash(clientUuid: string, infohash: string) {
saveManagedMap(data) saveManagedMap(data)
} }
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
function clearManagedHashes(clientUuid: string) { function clearManagedHashes(clientUuid: string) {
const data = loadManagedMap() const data = loadManagedMap()
if (!data[clientUuid]) return 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 } const client: SavedClient = { uuid: randomUUID(), name, type, config }
clients.push(client) clients.push(client)
saveClients(clients) saveClients(clients)
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`) logger.info('torrent-clients', `Client ajouté : « ${name} » (${type})`)
return client return client
} }
@@ -232,7 +216,7 @@ export function removeClient(uuid: string): boolean {
if (filtered.length === clients.length) return false if (filtered.length === clients.length) return false
saveClients(filtered) saveClients(filtered)
clearManagedHashes(uuid) clearManagedHashes(uuid)
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`) logger.info('torrent-clients', `Client supprimé : « ${target?.name} » (${target?.type})`)
return true return true
} }
@@ -246,7 +230,7 @@ export function updateClient(uuid: string, name: string, type: string, config: C
if (index === -1) return null if (index === -1) return null
clients[index] = { ...clients[index], name, type, config } clients[index] = { ...clients[index], name, type, config }
saveClients(clients) saveClients(clients)
logger.info('torrent-clients', `Client modifié : "${name}" (${type})`) logger.info('torrent-clients', `Client modifié : « ${name} » (${type})`)
return clients[index] return clients[index]
} }
@@ -262,8 +246,8 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config } return { ...client, config }
} }
// ─── Backoff par client ─────────────────────────────────────────────────────── // ─── Pause après échecs de connexion ──────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP // Client refusant l'authentification mis en pause pour éviter un bannissement d'IP
interface BackoffEntry { interface BackoffEntry {
failCount : number failCount : number
@@ -272,7 +256,6 @@ interface BackoffEntry {
const _backoff = new Map<string, 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] const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean { function isInCooldown(uuid: string): boolean {
@@ -286,7 +269,7 @@ function cooldownRemaining(uuid: string): string {
if (!entry) return '' if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now()) const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000) const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : '' return m > 0 ? `${m} min` : ''
} }
function registerFailure(uuid: string, clientName: string) { function registerFailure(uuid: string, clientName: string) {
@@ -296,14 +279,14 @@ function registerFailure(uuid: string, clientName: string) {
entry.nextRetry = Date.now() + delay entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry) _backoff.set(uuid, entry)
const min = Math.round(delay / 60_000) 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) { function registerSuccess(uuid: string) {
_backoff.delete(uuid) _backoff.delete(uuid)
} }
// Exposé pour l'API de status // Exposé pour l'API d'état
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> { export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {} const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) { for (const [uuid, entry] of _backoff.entries()) {
@@ -312,11 +295,9 @@ export function getClientCooldowns(): Record<string, { failCount: number; nextRe
return result return result
} }
// ─── Remote path remapping ──────────────────────────────────────────────────── // ─── Correspondance des chemins ───────────────────────────────────────────────
// FIX : remplace le préfixe remotePath par localPath dans save_path // Remplace le préfixe remotePath par localPath (ex. /downloads/x devient /mnt/nas/downloads/x)
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
function remapSavePath(savePath: string, remotePath: string, localPath: string): string { function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '') const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '') const local = localPath.replace(/\/+$/, '')
@@ -325,13 +306,13 @@ function remapSavePath(savePath: string, remotePath: string, localPath: string):
return local + savePath.slice(remote.length) 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 }[]> { export async function dispatchDownload(url: string, options?: DownloadOptions): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
const clients = loadClients() const clients = loadClients()
if (clients.length === 0) { 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 [] return []
} }
@@ -340,21 +321,21 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
for (const client of clients) { for (const client of clients) {
const driver = getDriver(client.type) const driver = getDriver(client.type)
if (!driver) { if (!driver) {
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`) 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: `Driver "${client.type}" introuvable` }) results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Type de client « ${client.type} » non pris en charge` })
continue continue
} }
try { try {
await driver.add(client.config, url, options) await driver.add(client.config, url, options)
if (options?.infohash) { if (options?.infohash) {
trackManagedHash(client.uuid, 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 }) results.push({ uuid: client.uuid, name: client.name, ok: true })
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`) logger.error('torrent-clients', `Échec de l'envoi à « ${client.name} » : ${msg}`)
results.push({ uuid: client.uuid, name: client.name, ok: false, error: 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 return results
} }
// ─── Dispatch remove ─────────────────────────────────────────────────────────── // ─── Suppression ──────────────────────────────────────────────────────────────
export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> { export async function dispatchRemove(hash: string, deleteFiles = false): Promise<{ ok: boolean; error?: string }> {
const clients = loadClients() const clients = loadClients()
@@ -372,18 +353,18 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
if (!driver) continue if (!driver) continue
try { try {
await driver.remove(client.config, hash, deleteFiles) 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) untrackManagedHash(client.uuid, hash)
found = true found = true
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' 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}`) 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> { function buildTitleIndex(infohashMap: Record<string, string>): Map<string, string> {
const index = new Map<string, string>() const index = new Map<string, string>()
for (const [hash, title] of Object.entries(infohashMap)) { for (const [hash, title] of Object.entries(infohashMap)) {
@@ -406,7 +387,7 @@ export async function dispatchList(
if (isInCooldown(client.uuid)) { if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(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 continue
} }
@@ -425,20 +406,16 @@ export async function dispatchList(
? remapSavePath(t.save_path, remotePath, localPath) ? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path : t.save_path
// Pour Synology (hash = dbid_xxx), on essaie de résoudre le vrai infohash // Synology (dbid_xxx) : retrouver l'infohash par le titre
// en matchant le titre du torrent dans la infohashMap
let hash = t.hash let hash = t.hash
if (titleIndex && t.hash.startsWith('dbid_')) { if (titleIndex && t.hash.startsWith('dbid_')) {
const resolved = titleIndex.get(t.name.toLowerCase().trim()) const resolved = titleIndex.get(t.name.toLowerCase().trim())
if (resolved) { 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 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) { if (driver.definition.filterByManagedHashes) {
const managed = getManagedSet(client.uuid) const managed = getManagedSet(client.uuid)
if (managed && !managed.has(hash)) continue 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 }) results.push({ ...t, hash, save_path, client_uuid: client.uuid, client_name: client.name })
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`) logger.error('torrent-clients', `Impossible de récupérer la liste des torrents de « ${client.name} » : ${msg}`)
const isAuthError = msg.toLowerCase().includes('login') || const lower = msg.toLowerCase()
msg.toLowerCase().includes('auth') || const isAuthError = ['login', 'auth', 'ban', 'forbidden', '401', '403', 'connexion refusée', 'refusé la connexion',
msg.toLowerCase().includes('ban') || 'connexion à deluge refusée', 'clé api', 'échec de la connexion'].some(k => lower.includes(k))
msg.toLowerCase().includes('forbidden') ||
msg.toLowerCase().includes('401') ||
msg.toLowerCase().includes('403')
if (isAuthError) registerFailure(client.uuid, client.name) if (isAuthError) registerFailure(client.uuid, client.name)
} }
} }
@@ -462,9 +436,7 @@ export async function dispatchList(
return results return results
} }
// ─── Dispatch file progress ──────────────────────────────────────────────────── // ─── Progression par fichier ──────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
export async function dispatchGetFiles( export async function dispatchGetFiles(
hash : string, hash : string,
+39 -62
View File
@@ -1,14 +1,10 @@
/** /** qBittorrent 4.x et 5.x (clé API à partir de 5.2.0). */
* qBittorrent Driver
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js' import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// qBittorrent répond normalement "Ok." mais certains setups (reverse proxy, etc.) // Réponse « Ok. », ou JSON derrière certains reverse proxies
// peuvent wrapper la réponse en JSON — on accepte les deux
function isQbAddSuccess(text: string): boolean { function isQbAddSuccess(text: string): boolean {
if (text.trim() === 'Ok.') return true if (text.trim() === 'Ok.') return true
try { try {
@@ -27,7 +23,6 @@ function mapState(state: string): TorrentInfo['state'] {
return 'unknown' return 'unknown'
} }
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: ClientConfig): Promise<Record<string, string>> { async function qbAuth(config: ClientConfig): Promise<Record<string, string>> {
if (config.apiKey) { if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` } return { Authorization: `Bearer ${config.apiKey}` }
@@ -48,7 +43,7 @@ async function qbLogin(config: ClientConfig): Promise<string> {
}) })
const text = await res.text() 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)') 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() 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( async function qbApplyFilePriority(
config : ClientConfig, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) { for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500)) await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try { try {
@@ -91,13 +81,10 @@ async function qbApplyFilePriority(
if (!Array.isArray(files) || files.length === 0) continue if (!Array.isArray(files) || files.length === 0) continue
if (fileIndex >= files.length) { 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), // Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
// 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.
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0) const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' } const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
@@ -107,37 +94,35 @@ async function qbApplyFilePriority(
if (unwanted.length > 0) { if (unwanted.length > 0) {
const body = new URLSearchParams({ hash, id: unwanted.join('|'), priority: '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 }) 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`, { const r2 = await clientFetch(config, `${config.url}/api/v2/torrents/filePrio`, {
method: 'POST', method: 'POST',
body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }), body : new URLSearchParams({ hash, id: String(fileIndex), priority: '1' }),
headers, 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 }) const verif = await clientFetch(config, `${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) { if (verif.ok) {
const verifiedFiles: any[] = await verif.json() const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex] const target = verifiedFiles[fileIndex]
if (!target || (target.priority ?? 0) === 0) { 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 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 return
} catch (err) { } catch (err) {
if (err instanceof Error && err.message.includes('hors limites')) throw 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 = { const QB: TorrentClientDriver = {
@@ -145,14 +130,14 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent', id : 'qbittorrent',
label : 'qBittorrent', label : 'qBittorrent',
fields: [ fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, { key: 'url', label: 'URL de la 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: 'apiKey', label: 'Clé API (qBittorrent 5.2 ou plus)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ 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: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, { 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: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { 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}`) logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('qbittorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -179,11 +164,11 @@ const QB: TorrentClientDriver = {
}) })
if (!res.ok) return { online: false } if (!res.ok) return { online: false }
const version = (await res.text()).trim() 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') 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', `Healthcheck OK — version ${version}`) logger.debug('qbittorrent', `Client en ligne (version ${version})`)
return { online: true, version } return { online: true, version }
} catch (err) { } 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 } return { online: false }
} }
}, },
@@ -195,10 +180,9 @@ const QB: TorrentClientDriver = {
const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, { const res = await clientFetch(config, `${config.url}/api/v2/torrents/info?${params}`, {
headers: authH, 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() 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[]>() const fileMap = new Map<string, any[]>()
await Promise.all(data.map(async (t: any) => { await Promise.all(data.map(async (t: any) => {
try { try {
@@ -255,7 +239,6 @@ const QB: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) { async add(config, url, options?: DownloadOptions) {
const authH = await qbAuth(config) const authH = await qbAuth(config)
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null) const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase() || 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() || options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
@@ -265,15 +248,15 @@ const QB: TorrentClientDriver = {
if (hash) { if (hash) {
const exists = await qbTorrentExists(config, authH, hash) const exists = await qbTorrentExists(config, authH, hash)
if (exists) { 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 => 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 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() let knownHashes: Set<string> = new Set()
if (!hash) { if (!hash) {
try { try {
@@ -282,10 +265,7 @@ const QB: TorrentClientDriver = {
} catch {} } catch {}
} }
// Stratégie d'ajout selon le type de lien : // .torrent récupéré par FanKarr : le client n'a pas forcément accès à Internet
// 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)
const isMagnet = url.startsWith('magnet:') const isMagnet = url.startsWith('magnet:')
let res: Response let res: Response
@@ -299,10 +279,9 @@ const QB: TorrentClientDriver = {
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
} else { } else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit logger.debug('qbittorrent', `Téléchargement du fichier .torrent par FanKarr : ${url.slice(0, 120)}`)
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } }) 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 torrentBytes = await torrentRes.arrayBuffer()
const form = new FormData() const form = new FormData()
if (config.category) form.append('category', String(config.category)) 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 }) res = await clientFetch(config, `${config.url}/api/v2/torrents/add`, { method: 'POST', body: form, headers: authH })
} }
const text = await res.text() 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) { 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 => 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 { } 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 (fichier n° ${options.file_index})`)
logger.info('qbittorrent', `Torrent ajouté sans hash connu — recherche dans la liste (sélection fichier ${options.file_index})`)
const fileIndex = options.file_index const fileIndex = options.file_index
;(async () => { ;(async () => {
for (let attempt = 0; attempt < 20; attempt++) { 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())) const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue if (!newT) continue
const resolvedHash = String(newT.hash).toLowerCase() 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) await qbApplyFilePriority(config, resolvedHash, fileIndex)
return return
} catch {} } 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(() => {}) })().catch(() => {})
} }
return return
} }
// Pas de sélection de fichier → ajout normal (URLSearchParams, pas de binaire)
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('urls', url) params.set('urls', url)
if (config.category) params.set('category', String(config.category)) 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' }, headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' },
}) })
const text = await res.text() 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}`)
logger.info('qbittorrent', `Torrent ajouté (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`) logger.info('qbittorrent', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
}, },
async remove(config, hash, deleteFiles = false) { async remove(config, hash, deleteFiles = false) {
@@ -366,7 +343,7 @@ const QB: TorrentClientDriver = {
body : params, body : params,
headers: { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }, 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)' : ''}`) logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
}, },
} }
+16 -37
View File
@@ -1,17 +1,4 @@
/** /** Real-Debrid : torrents téléchargés côté RD, fichiers lus depuis le dossier local monté (rclone, WebDAV…). */
* 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).
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js' import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions, ClientConfig } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
@@ -37,9 +24,10 @@ async function rdFetch(
body : body, body : body,
}) })
if (res.status === 204) return null 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) { if (!res.ok) {
const text = await res.text().catch(() => '') const data = await res.json().catch(() => null)
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`) throw new Error(`Real-Debrid a répondu HTTP ${res.status}${data?.error ? ` : ${data.error}` : ''}`)
} }
return res.json() return res.json()
} }
@@ -67,18 +55,18 @@ const rdDriver: TorrentClientDriver = {
label : 'Real-Debrid', label : 'Real-Debrid',
fields: [ fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true }, { 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) { async test(config) {
try { try {
const user = await rdFetch(config, 'GET', '/user') 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}` } return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`) logger.warn('real-debrid', `Échec du test de connexion : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -86,7 +74,7 @@ const rdDriver: TorrentClientDriver = {
async healthcheck(config) { async healthcheck(config) {
try { try {
const user = await rdFetch(config, 'GET', '/user') const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` } return { online: true, version: `Compte ${user.username}` }
} catch { } catch {
return { online: false } return { online: false }
} }
@@ -98,13 +86,12 @@ const rdDriver: TorrentClientDriver = {
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase() || url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null || null
// Vérifier si le torrent est déjà présent sur RD
if (hash) { if (hash) {
try { try {
const list: any[] = await rdFetch(config, 'GET', '/torrents') const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash) const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) { 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') { if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`) const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? []) .then((info: any) => info.files ?? [])
@@ -125,11 +112,10 @@ const rdDriver: TorrentClientDriver = {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet', const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url })) new URLSearchParams({ magnet: url }))
torrentId = data.id torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`) logger.info('real-debrid', `Magnet ajouté (ID ${torrentId})`)
} else { } else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url) 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 torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData() const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent') formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
@@ -138,15 +124,13 @@ const rdDriver: TorrentClientDriver = {
headers: { Authorization: `Bearer ${config.apiKey}` }, headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData, 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() const data = await uploadRes.json()
torrentId = data.id 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) { if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = [] let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) { for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
@@ -164,16 +148,13 @@ const rdDriver: TorrentClientDriver = {
if (fileId) { if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`, await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) })) 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) { } else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`, await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' })) new URLSearchParams({ files: 'all' }))
} }
} else { } else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) { for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
try { try {
@@ -195,10 +176,9 @@ const rdDriver: TorrentClientDriver = {
return torrents.map((t: any): TorrentInfo => { return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0 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}` : '' 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 = const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0 Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({ ? t.files.map((f: any, i: number) => ({
@@ -228,7 +208,6 @@ const rdDriver: TorrentClientDriver = {
}, },
async remove(config, hash, _deleteFiles?) { async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents') const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase()) 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`) if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
+28 -52
View File
@@ -1,15 +1,10 @@
/** /** rTorrent via XML-RPC (ruTorrent ou rTorrent seul, sur /RPC2 ou /XMLRPC). */
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js' import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js' import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// ─── XML-RPC helpers ────────────────────────────────────────── // ─── XML-RPC ──────────────────────────────────────────────────
function xmlValue(val: any): string { function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>` 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 url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await clientFetch(config, url, { method: 'POST', headers, body }) 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() 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>/) 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>/) const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null if (!valueMatch) return null
// Parse simplifié pour les types courants // Types courants uniquement
const inner = valueMatch[1].trim() const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) { if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)] const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim())) return items.map(m => parseInnerValue(m[1].trim()))
@@ -108,7 +102,6 @@ function parseInnerValue(inner: string): any {
return inner return inner
} }
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall( async function d_multicall(
config : ClientConfig, config : ClientConfig,
view : string, view : string,
@@ -128,28 +121,20 @@ async function rtTorrentExists(config: ClientConfig, hash: string): Promise<bool
} }
} }
/** /** Attend les métadonnées puis applique les priorités, avant le début du téléchargement. */
* 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.
*/
async function rtApplyFilePriority( async function rtApplyFilePriority(
config : ClientConfig, config : ClientConfig,
hash : string, hash : string,
fileIndex: number, fileIndex: number,
): Promise<void> { ): Promise<void> {
// 100 tentatives × 300 ms = 30 s max
for (let attempt = 0; attempt < 100; attempt++) { for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 300)) await new Promise(r => setTimeout(r, 300))
try { 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 files: any[] = await rpcCall(config, 'f.multicall', [hash, '', 'f.size_bytes=', 'f.priority='])
const fileCount = Array.isArray(files) ? files.length : 0 const fileCount = Array.isArray(files) ? files.length : 0
if (fileCount === 0) continue if (fileCount === 0) continue
// Si tous les fichiers sont à priorité normale (état initial), // Premier épisode choisi : tout désactiver sauf la cible ; sinon activer seulement la cible
// on filtre tout sauf la cible. Sinon on active juste la cible.
const isInitialState = files.every((f: any) => Number(f[1]) > 0) const isInitialState = files.every((f: any) => Number(f[1]) > 0)
for (let i = 0; i < fileCount; i++) { for (let i = 0; i < fileCount; i++) {
@@ -158,20 +143,17 @@ async function rtApplyFilePriority(
} else if (isInitialState) { } else if (isInitialState) {
await rpcCall(config, 'f.priority.set', [hash, i, 0]) 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 return
} catch {} } 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 ──────────────────────────────────────────── // ─── États ────────────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] { function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking' if (isChecking) return 'checking'
if (!isOpen) return 'paused' if (!isOpen) return 'paused'
@@ -187,12 +169,12 @@ const RT: TorrentClientDriver = {
fields: [ fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true }, { 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: '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: '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: '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: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
], ],
}, },
@@ -200,11 +182,11 @@ const RT: TorrentClientDriver = {
async test(config) { async test(config) {
try { try {
const version = await rpcCall(config, 'system.client_version') 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' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('rtorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -212,10 +194,10 @@ const RT: TorrentClientDriver = {
async healthcheck(config) { async healthcheck(config) {
try { try {
const version = await rpcCall(config, 'system.client_version') 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) } return { online: true, version: String(version) }
} catch (err) { } 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 } return { online: false }
} }
}, },
@@ -230,8 +212,8 @@ const RT: TorrentClientDriver = {
'd.complete', 'd.complete',
'd.size_bytes', 'd.size_bytes',
'd.bytes_done', 'd.bytes_done',
'd.up.total', // total uploadé 'd.up.total',
'd.ratio', // ratio x1000 'd.ratio', // ratio × 1000
'd.down.rate', 'd.down.rate',
'd.up.rate', 'd.up.rate',
'd.left_bytes', 'd.left_bytes',
@@ -241,7 +223,6 @@ const RT: TorrentClientDriver = {
const rows = await d_multicall(config, 'main', methods) 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[]>() const fileMap = new Map<string, any[]>()
await Promise.all(rows.map(async (r: any) => { await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase() const hash = String(r[0]).toUpperCase()
@@ -301,42 +282,37 @@ const RT: TorrentClientDriver = {
const exists = await rtTorrentExists(config, hash) const exists = await rtTorrentExists(config, hash)
if (exists) { 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, sélection du fichier n° ${options.file_index}`)
logger.info('rtorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
rtApplyFilePriority(config, hash, options.file_index).catch(err => 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 return
} }
// Nouveau torrent → load.start (fetch métadonnées via DHT/peers actif) // Nouveau torrent : priorités appliquées dès que load.start a récupéré les métadonnées
// 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.
const args: any[] = ['', url] const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`) if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`) if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args) 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 => 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 return
} }
// Pas de sélection de fichier → ajout et démarrage normal
const args: any[] = ['', url] const args: any[] = ['', url]
if (config.savePath) args.push(`d.directory.set="${config.savePath}"`) if (config.savePath) args.push(`d.directory.set="${config.savePath}"`)
if (config.category) args.push(`d.custom1.set=${config.category}`) if (config.category) args.push(`d.custom1.set=${config.category}`)
await rpcCall(config, 'load.start', args) 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) { async remove(config, hash, deleteFiles = false) {
const h = hash.toUpperCase() const h = hash.toUpperCase()
if (deleteFiles) { if (deleteFiles) {
// Supprimer les fichiers puis effacer
await rpcCall(config, 'd.delete_tied', [h]) await rpcCall(config, 'd.delete_tied', [h])
await rpcCall(config, 'd.erase', [h]) await rpcCall(config, 'd.erase', [h])
} else { } else {
+60 -49
View File
@@ -1,6 +1,3 @@
/**
* Synology Download Station Driver
*/
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
@@ -9,18 +6,13 @@ import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { DATA_DIR } from '../config.js' import { DATA_DIR } from '../config.js'
// ─── Cache URI → infohash ────────────────────────────────────────────────────── // ─── Cache URI et infohash ─────────────────────────────────────────────────────
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM). // DSM anciens : pas de hash BT dans l'API, on mémorise l'URI et son infohash à l'ajout
// 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.
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json') const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
// ─── Cache hash BT → ID Synology (en mémoire) ───────────────────────────────── // ─── Cache hash BT et ID Synology (en mémoire) ────────────────────────────────
// Synology utilise des IDs internes (dbid_X), pas des infohash BT. // Hash BT vers dbid_X, pour que remove() et getFiles() retrouvent la tâche
// 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.
const _hashToSynoId = new Map<string, string>() const _hashToSynoId = new Map<string, string>()
function loadUriHashMap(): Record<string, string> { function loadUriHashMap(): Record<string, string> {
@@ -38,7 +30,7 @@ const _uriToHash: Map<string, string> = (() => {
function storeUriHash(uri: string, hash: string) { function storeUriHash(uri: string, hash: string) {
const key = uri.toLowerCase() 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) _uriToHash.set(key, hash)
try { try {
const map = loadUriHashMap() 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) // 1. Hash direct depuis l'API Synology (DSM récent)
if (detailHash.length > 0) return detailHash.toLowerCase() 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:')) { if (detailUri.startsWith('magnet:')) {
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i) const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
if (m) return m[1].toLowerCase() 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) { if (detailUri) {
const cached = _uriToHash.get(detailUri.toLowerCase()) const cached = _uriToHash.get(detailUri.toLowerCase())
if (cached) return cached if (cached) return cached
} }
// 4. Fallback : ID Synology natif (déjà au format "dbid_X") // 4. À défaut : ID Synology natif (dbid_X)
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`) logger.debug('synology-ds', `Hash BT non résolu, ID Synology utilisé : ${taskId}`)
return String(taskId ?? '').toLowerCase() return String(taskId ?? '').toLowerCase()
} }
// ─── State mapping ───────────────────────────────────────────────────────────── // ─── États ─────────────────────────────────────────────────────────────────────
function mapState(status: string): TorrentInfo['state'] { function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading' if (status === 'downloading') return 'downloading'
@@ -82,7 +74,31 @@ function mapState(status: string): TorrentInfo['state'] {
return 'unknown' 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> { async function dsLogin(config: ClientConfig): Promise<string> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -95,9 +111,9 @@ async function dsLogin(config: ClientConfig): Promise<string> {
format : 'sid', format : 'sid',
}) })
const res = await clientFetch(config, `${config.url}/webapi/auth.cgi?${params}`) 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() 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 return data.data.sid
} }
@@ -111,13 +127,13 @@ async function dsRequest(
): Promise<any> { ): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra }) const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`) 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() 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 return data.data
} }
// ─── Driver ──────────────────────────────────────────────────────────────────── // ─── Client ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = { const DS: TorrentClientDriver = {
definition: { definition: {
@@ -125,13 +141,13 @@ const DS: TorrentClientDriver = {
label : 'Synology Download Station', label : 'Synology Download Station',
filterByManagedHashes: true, filterByManagedHashes: true,
fields : [ fields : [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true }, { key: 'url', label: 'URL de DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', 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: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, { 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: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { 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}`) logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('synology-ds', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -175,8 +191,7 @@ const DS: TorrentClientDriver = {
const tasks: any[] = data?.tasks ?? [] const tasks: any[] = data?.tasks ?? []
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories. // Pas de catégories chez Synology : filtre sur le dossier de téléchargement s'il est renseigné
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase() const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
const filtered = normalizedSavePath const filtered = normalizedSavePath
? tasks.filter(t => { ? tasks.filter(t => {
@@ -198,7 +213,6 @@ const DS: TorrentClientDriver = {
String(t.id ?? ''), String(t.id ?? ''),
) )
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
const synoIdStr = String(t.id ?? '').toLowerCase() const synoIdStr = String(t.id ?? '').toLowerCase()
if (!hash.startsWith('dbid_') && synoIdStr) { if (!hash.startsWith('dbid_') && synoIdStr) {
_hashToSynoId.set(hash, synoIdStr) _hashToSynoId.set(hash, synoIdStr)
@@ -238,17 +252,16 @@ const DS: TorrentClientDriver = {
async add(config, url, options?: DownloadOptions) { async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) 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) { if (options?.infohash) {
storeUriHash(url, 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) 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>() const beforeIds = new Set<string>()
try { try {
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid) 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)) if (withDestination && config.savePath) params.append('destination', String(config.savePath))
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`) 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() return res.json()
} }
let data = await doAdd(true) 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) { 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) 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) { if (options?.infohash) {
try { try {
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid) const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
@@ -288,12 +299,12 @@ const DS: TorrentClientDriver = {
if (newTask) { if (newTask) {
const synoId = String(newTask.id).toLowerCase() const synoId = String(newTask.id).toLowerCase()
_hashToSynoId.set(options.infohash.toLowerCase(), synoId) _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 {} } 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) { async getFiles(config, hash) {
@@ -358,7 +369,7 @@ const DS: TorrentClientDriver = {
_sid : sid, _sid : sid,
}) })
const res = await clientFetch(config, `${config.url}/webapi/DownloadStation/task.cgi?${params}`) 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é`) 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 type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js' import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] { function mapState(status: number): TorrentInfo['state'] {
// Transmission status codes : // 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
// 4 = downloading, 5 = seed queued, 6 = seeding
if (status === 4 || status === 3) return 'downloading' if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding' if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused' if (status === 0) return 'paused'
@@ -38,17 +33,17 @@ async function trRequest(
body : JSON.stringify({ method, arguments: args }), 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) { if (res.status === 409) {
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? '' 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) 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() 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 } 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 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( async function trApplyFilePriority(
config : ClientConfig, config : ClientConfig,
hash : string, hash : string,
@@ -84,11 +75,11 @@ async function trApplyFilePriority(
'files-wanted' : [fileIndex], 'files-wanted' : [fileIndex],
'files-unwanted' : unwanted, '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 return
} catch {} } 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 = { const TR: TorrentClientDriver = {
@@ -97,12 +88,12 @@ const TR: TorrentClientDriver = {
label : 'Transmission', label : 'Transmission',
fields: [ fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true }, { 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: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, { 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: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { 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}`) logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -123,10 +114,10 @@ const TR: TorrentClientDriver = {
try { try {
const data = await trRequest(config, 'session-get') const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue' 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 } return { online: true, version }
} catch (err) { } 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 } return { online: false }
} }
}, },
@@ -136,7 +127,7 @@ const TR: TorrentClientDriver = {
'hashString', 'name', 'status', 'percentDone', 'totalSize', 'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'uploadedEver', 'uploadRatio', 'downloadedEver', 'uploadedEver', 'uploadRatio',
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels', '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 data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? [] const torrents: any[] = data.arguments?.torrents ?? []
@@ -184,21 +175,21 @@ const TR: TorrentClientDriver = {
if (options?.file_index != null && hash) { if (options?.file_index != null && hash) {
const isDuplicate = !!res.arguments?.['torrent-duplicate'] const isDuplicate = !!res.arguments?.['torrent-duplicate']
if (isDuplicate) { 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 { } 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 => 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 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) { 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 data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
const torrents: any[] = data.arguments?.torrents ?? [] const torrents: any[] = data.arguments?.torrents ?? []
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase()) 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 type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
import { clientFetch } from './index.js' import { clientFetch } from './index.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
// uTorrent status flags (bitmask) : // 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é
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
function mapState(status: number): TorrentInfo['state'] { function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error' if (status & 16) return 'error'
if (status & 32) return 'paused' if (status & 32) return 'paused'
if (status & 2) return 'checking' 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 & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued if (status & 64) return 'downloading'
return 'unknown' return 'unknown'
} }
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string } interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: ClientConfig, hash: string): Promise<boolean> { 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 } } catch { return false }
} }
/** /** Attend la liste des fichiers puis les passe à 0, sauf la cible à 2 (normale). */
* 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).
*/
async function utApplyFilePriority( async function utApplyFilePriority(
config : ClientConfig, config : ClientConfig,
hash : string, hash : string,
@@ -43,7 +34,6 @@ async function utApplyFilePriority(
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`) const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase() const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) { for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
try { try {
@@ -52,24 +42,22 @@ async function utApplyFilePriority(
const files = data?.files?.[1] as any[][] | undefined const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue 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' }) 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)) for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await clientFetch(config, `${config.url}/gui/?${skipQs}`, { await clientFetch(config, `${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, 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) }) const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await clientFetch(config, `${config.url}/gui/?${selectQs}`, { await clientFetch(config, `${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, 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 return
} catch {} } 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> { 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 }) 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 text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</) 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 token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? '' const cookie = res.headers.get('set-cookie') ?? ''
@@ -104,15 +93,12 @@ async function utRequest(
'Cookie' : session.cookie, '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() return res.json()
} }
function mapTorrent(t: any[]): TorrentInfo { function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent : // Tableau par torrent : [hash, status, name, size, progress, downloaded, uploaded, ratio, ul_speed, dl_speed, eta, label, …]
// [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]
const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t const [hash, status, name, size, progress, downloaded, uploaded, ratio, ulSpeed, dlSpeed, eta, label] = t
return { return {
hash : hash.toLowerCase(), hash : hash.toLowerCase(),
@@ -122,7 +108,7 @@ function mapTorrent(t: any[]): TorrentInfo {
size, size,
downloaded, downloaded,
uploaded : uploaded ?? 0, 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, speed : dlSpeed ?? 0,
upspeed : ulSpeed ?? 0, upspeed : ulSpeed ?? 0,
eta : eta ?? -1, eta : eta ?? -1,
@@ -136,13 +122,13 @@ const UT: TorrentClientDriver = {
id : 'utorrent', id : 'utorrent',
label : 'uTorrent', label : 'uTorrent',
fields: [ fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true }, { key: 'url', label: 'URL de la WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', 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: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' }, { 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: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false }, { key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/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 }, { 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}`) logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' } return { ok: true, message: 'Connexion réussie' }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`) logger.warn('utorrent', `Échec du test de connexion sur ${config.url} : ${msg}`)
return { ok: false, message: msg } return { ok: false, message: msg }
} }
}, },
@@ -164,10 +150,10 @@ const UT: TorrentClientDriver = {
const session = await utGetSession(config) const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session) const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue' 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 } return { online: true, version }
} catch (err) { } 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 } return { online: false }
} }
}, },
@@ -185,7 +171,6 @@ const UT: TorrentClientDriver = {
.map(t => { .map(t => {
const info = mapTorrent(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) const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? '' if (props) info.save_path = props[1] ?? ''
@@ -201,9 +186,9 @@ const UT: TorrentClientDriver = {
if (options?.file_index != null && hash) { if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash) const exists = await utTorrentExists(config, hash)
if (exists) { 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 => 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 return
} }
@@ -219,18 +204,17 @@ const UT: TorrentClientDriver = {
method : 'GET', method : 'GET',
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie }, 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) { 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 => 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 { } 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) { if (config.category) {
setTimeout(async () => { setTimeout(async () => {
try { 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 fs from 'fs'
import path from 'path' 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 LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
// ── Migration mono → multi-user ─────────────────────────────── // ── Migration depuis auth.json ────────────────────────────────
export function migrateIfNeeded(): void { export function migrateIfNeeded(): void {
if (fs.existsSync(USERS_PATH)) return // déjà en format multi-user if (fs.existsSync(USERS_PATH)) return
if (!fs.existsSync(LEGACY_PATH)) return // pas encore configuré if (!fs.existsSync(LEGACY_PATH)) return
try { try {
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as { const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
@@ -47,13 +43,13 @@ export function migrateIfNeeded(): void {
createdAt : new Date().toISOString(), createdAt : new Date().toISOString(),
} }
writeUsers([admin]) 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) { } 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[] { export function readUsers(): User[] {
try { 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 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) } 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) { export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user const { passwordHash: _, ...safe } = user
return safe return safe
} }
// ── CRUD ────────────────────────────────────────────────────── // ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User { export function createUser(username: string, password: string, role: UserRole = 'user'): User {
const users = readUsers() const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) { 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 = { const user: User = {
id : crypto.randomUUID(), id : crypto.randomUUID(),
@@ -97,7 +92,7 @@ export function createUser(username: string, password: string, role: UserRole =
tourSeenAt : null, tourSeenAt : null,
} }
writeUsers([...users, user]) writeUsers([...users, user])
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`) logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user return user
} }
@@ -113,7 +108,7 @@ export function updateUser(
if (changes.username !== undefined && changes.username !== user.username) { if (changes.username !== undefined && changes.username !== user.username) {
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) { 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 user.username = changes.username
} }
@@ -121,7 +116,7 @@ export function updateUser(
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS) if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
writeUsers(users) writeUsers(users)
logger.info('users', `Utilisateur "${user.username}" mis à jour`) logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
return user return user
} }
@@ -130,7 +125,7 @@ export function deleteUser(id: string): void {
const user = users.find(u => u.id === id) const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable') if (!user) throw new Error('Utilisateur introuvable')
writeUsers(users.filter(u => u.id !== id)) 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 { 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) user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
writeUsers(users) 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 { export function markLogin(id: string): string | null {
+3 -4
View File
@@ -48,7 +48,7 @@ export interface Torrent {
url: string url: string
} }
// Parse "Nom@url|Nom@url" → Torrent[] // "Nom@url|Nom@url" en liste de torrents
export function parseTorrents(raw: string | null): Torrent[] { export function parseTorrents(raw: string | null): Torrent[] {
if (!raw) return [] if (!raw) return []
return raw.split('|').flatMap(item => { 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 { export function imageUrl(path: string | null): string | null {
if (!path) return null if (!path) return null
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`) 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[]> { export async function fetchProductions(): Promise<Production[]> {
const res = await fetch(`${BASE}/productions`) 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() const data = await res.json()
return data.pageProps.data return data.pageProps.data
} }
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> { export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`) 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() const data = await res.json()
return data.pageProps.data return data.pageProps.data
} }
+3 -3
View File
@@ -167,7 +167,7 @@ body {
.pill-err { @apply border-err/35 text-err; } .pill-err { @apply border-err/35 text-err; }
.pill-muted { @apply border-border-light text-muted; } .pill-muted { @apply border-border-light text-muted; }
/* Filtres et onglets compteurs */ /* Filtres et onglets à compteur */
.chip { .chip {
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium @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; 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; @apply translate-x-5 bg-on-accent;
} }
/* Sélecteur */ /* Sélecteur segmenté */
.segmented { .segmented {
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light; @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; @apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
} }
/* Menus déroulants et pop */ /* Menus déroulants et modales */
.menu { .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; @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="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"> <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="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
<div class="min-w-0"> <div class="min-w-0">
<p class="card-title">Choisir un dossier</p> <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> <button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div> </div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2"> <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'"> <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" /> <House :size="15" />
@@ -25,22 +23,20 @@
aria-label="Chemin" aria-label="Chemin"
/> />
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]"> <button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
OK Aller
</button> </button>
</div> </div>
<!-- Retour parent -->
<div v-if="parent !== null" class="px-5 shrink-0"> <div v-if="parent !== null" class="px-5 shrink-0">
<button <button
@click="navigateTo(parent)" @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" 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" /> <ArrowLeft :size="14" />
.. Dossier parent
</button> </button>
</div> </div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1"> <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 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" /> <div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
@@ -59,10 +55,8 @@
</button> </button>
</div> </div>
<!-- Footer -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0"> <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> <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"> <button @click="selectCurrent()" class="btn-primary">
Choisir Choisir
</button> </button>
@@ -93,7 +87,7 @@ function selectCurrent() {
} }
function joinPath(base: string, name: string): string { 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 (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}` if (base === '/') return `/${name}`
const cleanBase = base.replace(/[\\/]+$/, '') const cleanBase = base.replace(/[\\/]+$/, '')
@@ -107,7 +101,7 @@ async function navigateTo(p: string) {
try { try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' }) const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) { if (!res.ok) {
error.value = (await res.json()).error ?? 'Erreur' error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
return return
} }
const data = await res.json() const data = await res.json()
@@ -117,7 +111,7 @@ async function navigateTo(p: string) {
dirs.value = data.dirs dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false drivesRoot.value = data.drivesRoot ?? false
} catch { } catch {
error.value = 'Impossible de lire ce dossier' error.value = 'Impossible de lire ce dossier.'
} finally { } finally {
loading.value = false loading.value = false
} }
+31 -45
View File
@@ -3,47 +3,42 @@
<div class="modal-backdrop" @click.self="$emit('close')"> <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"> <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="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"> <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> <p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
</div> </div>
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button> <button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div> </div>
<!-- Chargement -->
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted"> <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" /> <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> </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"> <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-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"> <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. {{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
</p> </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> </div>
<!-- Erreur -->
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6"> <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> <p class="text-body text-err">{{ scanError }}</p>
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button> <button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
</div> </div>
<!-- Liste des fichiers -->
<template v-else-if="items.length > 0 && step !== 'done'"> <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"> <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"> <p class="text-meta text-muted">
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }} <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 v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
· <span class="text-accent">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span> · <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
<span v-if="unmatchedCount > 0"> · {{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span> <span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
</p> </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>
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2"> <div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
@@ -80,7 +75,7 @@
@change="onEpisodeChange(i)" @change="onEpisodeChange(i)"
:aria-label="`Épisode associé à ${item.file.name}`" :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}`"> <optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option <option
v-for="ep in season.episodes" v-for="ep in season.episodes"
@@ -88,8 +83,8 @@
:value="ep.id" :value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)" :disabled="isEpisodeAlreadyUsed(ep.id, i)"
> >
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }} E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }} {{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
</option> </option>
</optgroup> </optgroup>
</select> </select>
@@ -108,14 +103,13 @@
:disabled="newMatchedCount === 0 || importing" :disabled="newMatchedCount === 0 || importing"
class="btn-primary" class="btn-primary"
> >
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }} {{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
</button> </button>
</div> </div>
</div> </div>
</template> </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"> <div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
<span <span
class="w-12 h-12 rounded-full flex items-center justify-center" class="w-12 h-12 rounded-full flex items-center justify-center"
@@ -127,16 +121,16 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="font-display text-xl font-bold text-primary">Import terminé</p> <p class="font-display text-xl font-bold text-primary">Import terminé</p>
<p class="text-body text-secondary"> <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"> <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> </span>
</p> </p>
</div> </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"> <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 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"> <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> </p>
</div> </div>
<div class="flex gap-2.5"> <div class="flex gap-2.5">
@@ -155,6 +149,7 @@ import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next' import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
import { matchEpisodeFile } from '@/utils/episode-match' import { matchEpisodeFile } from '@/utils/episode-match'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast() const { add: toast } = useToast()
@@ -171,7 +166,7 @@ const emit = defineEmits<{
imported: [] imported: []
}>() }>()
// ─── State ──────────────────────────────────────────────────── // ── État ──
const step = ref<'list' | 'done'>('list') const step = ref<'list' | 'done'>('list')
const scanning = ref(false) const scanning = ref(false)
const importing = ref(false) const importing = ref(false)
@@ -189,7 +184,7 @@ const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
interface FileItem { interface FileItem {
file : { name: string; path: string; size: number } file : { name: string; path: string; size: number }
episode_id : number | null 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 hash : string | null
alreadyImported : boolean alreadyImported : boolean
willRename : boolean willRename : boolean
@@ -197,12 +192,12 @@ interface FileItem {
const items = ref<FileItem[]>([]) const items = ref<FileItem[]>([])
// ─── Computed ────────────────────────────────────────────────── // ── Calculs ──
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length) 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 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) const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ────────────────────────────────────────────────── // ── Utilitaires ──
function isEpisodeOrganized(epId: number): boolean { function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)] 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) } 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) { function onEpisodeChange(i: number) {
const item = items.value[i] const item = items.value[i]
if (!item) return if (!item) return
@@ -252,8 +240,7 @@ function onEpisodeChange(i: number) {
item.alreadyImported = false item.alreadyImported = false
item.willRename = newEpId !== null item.willRename = newEpId !== null
// Retirer les autres fichiers "déjà importés" pour ce même épisode : // Le fichier choisi remplace ceux déjà importés pour cet épisode
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
if (newEpId !== null) { if (newEpId !== null) {
items.value = items.value.filter(other => items.value = items.value.filter(other =>
other === item || !(other.episode_id === newEpId && other.alreadyImported) other === item || !(other.episode_id === newEpId && other.alreadyImported)
@@ -261,7 +248,7 @@ function onEpisodeChange(i: number) {
} }
} }
// ─── Actions ────────────────────────────────────────────────── // ── Actions ──
async function scan() { async function scan() {
scanning.value = true scanning.value = true
scanError.value = '' scanError.value = ''
@@ -271,7 +258,7 @@ async function scan() {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' }) const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
scanError.value = data.error ?? 'Erreur lors du scan' scanError.value = data.error ?? "Impossible d'analyser le dossier."
return return
} }
const data = await res.json() const data = await res.json()
@@ -281,7 +268,6 @@ async function scan() {
const organizedByPath = buildOrganizedByPath() const organizedByPath = buildOrganizedByPath()
items.value = files.map(f => { items.value = files.map(f => {
// Vérifier si déjà importé via dest_path
const existingEpId = organizedByPath.get(f.path) ?? null const existingEpId = organizedByPath.get(f.path) ?? null
if (existingEpId) { if (existingEpId) {
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false } 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 } return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
}) })
} catch { } catch {
scanError.value = 'Impossible de contacter le serveur' scanError.value = 'Impossible de contacter le serveur.'
} finally { } finally {
scanning.value = false 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 })) .map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
if (payload.length === 0) { if (payload.length === 0) {
importError.value = 'Aucun nouveau fichier à importer' importError.value = 'Aucun nouveau fichier à importer.'
importing.value = false importing.value = false
return return
} }
@@ -320,26 +306,26 @@ async function doImport() {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
importError.value = data.error ?? 'Erreur inconnue' importError.value = data.error ?? "Impossible d'importer les fichiers."
toast(data.error ?? 'Erreur lors de l\'import', 'error') toast(data.error ?? "Impossible d'importer les fichiers", 'error')
return return
} }
importResult.value = { done: data.done, errors: data.errors ?? [] } importResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) { 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) { 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') emit('imported')
step.value = 'done' step.value = 'done'
} catch { } catch {
importError.value = 'Impossible de contacter le serveur' importError.value = 'Impossible de contacter le serveur.'
toast('Impossible de contacter le serveur', 'error') toast('Impossible de contacter le serveur', 'error')
} finally { } finally {
importing.value = false importing.value = false
@@ -348,7 +334,7 @@ async function doImport() {
function backToList() { function backToList() {
step.value = 'list' 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) onMounted(scan)
-1
View File
@@ -28,7 +28,6 @@
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span> <span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
<!-- Nbre de messages regroupés -->
<span <span
v-if="t.count && t.count > 1" v-if="t.count && t.count > 1"
class="shrink-0 pill h-5 px-2 text-[10.5px]" class="shrink-0 pill h-5 px-2 text-[10.5px]"
@@ -1,6 +1,5 @@
<template> <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"> <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"> <div class="chip-row">
<button <button
v-for="t in tabs" :key="t.value" v-for="t in tabs" :key="t.value"
@@ -32,7 +31,7 @@
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents" 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" 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" /> <X :size="13" />
</button> </button>
</label> </label>
@@ -46,7 +45,6 @@
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button> ><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
</label> </label>
<!-- Tri, colonnes, seeds -->
<div ref="optionsRef" class="relative"> <div ref="optionsRef" class="relative">
<button <button
@click="optionsOpen = !optionsOpen" @click="optionsOpen = !optionsOpen"
@@ -79,7 +77,7 @@
<div class="h-px bg-hover my-1" /> <div class="h-px bg-hover my-1" />
<label class="menu-item cursor-pointer justify-between"> <label class="menu-item cursor-pointer justify-between">
Seeds uniquement Masquer les états inconnus
<button <button
type="button" role="switch" :aria-checked="seedingOnly" type="button" role="switch" :aria-checked="seedingOnly"
@click="emit('update:seedingOnly', !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"> <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"> <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"> <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]" /> <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> <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> </span>
<div class="flex-1 min-w-0 flex flex-col gap-[7px]"> <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="flex items-center gap-2.5 min-w-0">
<div class="relative group/eps 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"> <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 }} {{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
</p> </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 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"> <div class="menu p-3 gap-0">
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p> <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 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"> <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> <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="pill h-5 px-2 text-[10.5px]"
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'" :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> </span>
</div> </div>
</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> <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> </div>
<!-- Progression, vitesse, ETA -->
<template v-if="!isDone"> <template v-if="!isDone">
<div class="progress"> <div class="progress">
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" /> <div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
</div> </div>
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted"> <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'">{{ 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="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> <span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
</div> </div>
</template> </template>
<!-- Terminé : erreurs d'import ou détails -->
<template v-else> <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"> <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" /> <TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
@@ -75,20 +70,19 @@
<span> <span>
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }} <template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
</span> </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-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</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.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span> <span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<!-- État d'import et actions -->
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap"> <div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
<template v-if="!isDone"> <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>
<template v-else> <template v-else>
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto"> <span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
@@ -109,30 +103,29 @@
</button> </button>
</template> </template>
<!-- Supprimer -->
<div class="relative"> <div class="relative">
<button <button
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)" @click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
:disabled="deleting" :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" 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'" :title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'" :aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
> >
<Trash2 :size="14" /> <Trash2 :size="14" />
</button> </button>
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3"> <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"> <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> </p>
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer"> <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" /> <input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
Supprimer aussi les fichiers Supprimer aussi les fichiers du disque
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1"> <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>
<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> </div>
</div> </div>
@@ -145,7 +138,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next' 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<{ const props = defineProps<{
torrent : any torrent : any
@@ -172,7 +165,7 @@ const isImported = computed(() => isDone.value && props.torrent.organizeState ==
const clientLine = computed(() => [ const clientLine = computed(() => [
props.columns.client ? props.torrent.client_name : null, 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(' · ')) ].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})` : ''}` 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 episodesWithProgress = computed(() => {
const eps: any[] = props.torrent.episodes ?? [] const eps: any[] = props.torrent.episodes ?? []
if (!eps.length) return [] if (!eps.length) return []
@@ -208,5 +200,5 @@ const badge = computed(() => ({
checking : { label: 'Vérification', class: 'pill-wait' }, checking : { label: 'Vérification', class: 'pill-wait' },
error : { label: 'Erreur', class: 'pill-err' }, error : { label: 'Erreur', class: 'pill-err' },
unknown : { label: 'État inconnu', class: 'pill-wait' }, 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> </script>
-1
View File
@@ -13,7 +13,6 @@
<Search :size="19" :stroke-width="1.75" /> <Search :size="19" :stroke-width="1.75" />
</button> </button>
<!-- Menu du compte -->
<div ref="menuRef" class="relative"> <div ref="menuRef" class="relative">
<button <button
data-tour="help" data-tour="help"
+1 -3
View File
@@ -40,7 +40,6 @@
</template> </template>
</nav> </nav>
<!-- Nouvelle version disponible -->
<a <a
v-if="auth.isAdmin && updateAvailable" v-if="auth.isAdmin && updateAvailable"
:href="latestReleaseUrl" :href="latestReleaseUrl"
@@ -51,12 +50,11 @@
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" /> <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="flex-1 min-w-0">
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span> <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> </span>
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" /> <ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
</a> </a>
<!-- Utilisateur et déconnexion -->
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card"> <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"> <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 }} {{ initial }}
@@ -8,7 +8,7 @@
</div> </div>
<div class="flex flex-col gap-[7px]"> <div class="flex flex-col gap-[7px]">
<label for="reject-message" class="field-label">Motif (facultatif)</label> <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>
<div class="flex gap-2.5 justify-end"> <div class="flex gap-2.5 justify-end">
<button @click="emit('close')" class="btn-ghost">Annuler</button> <button @click="emit('close')" class="btn-ghost">Annuler</button>
+15 -11
View File
@@ -1,13 +1,16 @@
<template> <template>
<section class="relative overflow-hidden"> <section class="relative">
<img <!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
v-if="serie.poster_image" <div class="absolute inset-0 overflow-hidden pointer-events-none">
:src="serie.poster_image" <img
alt="" v-if="serie.poster_image"
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50 pointer-events-none" :src="serie.poster_image"
/> alt=""
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" /> class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" /> />
<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="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]"> <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'" :class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
>{{ statusLabel(serie.status) }}</span> >{{ statusLabel(serie.status) }}</span>
<span v-if="episodeCount" class="text-body text-secondary"> <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>
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5"> <span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
<Rss :size="11" :stroke-width="2.5" /> Surveillée <Rss :size="11" :stroke-width="2.5" /> Surveillée
@@ -75,7 +78,7 @@
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }" :style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
/> />
</div> </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>
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap"> <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 { RouterLink } from 'vue-router'
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next' import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
import { statusLabel } from '@/utils/series' import { statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
defineProps<{ defineProps<{
serie : any serie : any
+46 -85
View File
@@ -1,6 +1,5 @@
<template> <template>
<section class="bg-card rounded-card"> <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 }"> <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 <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" 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)"> <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"> <h2 class="font-display text-[17px] font-bold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }} {{ 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> </h2>
<p class="text-meta text-muted">{{ seasonMeta }}</p> <p class="text-meta text-muted">{{ seasonMeta }}</p>
</div> </div>
<div class="flex items-center gap-2.5 flex-wrap"> <div class="flex items-center gap-2.5 flex-wrap">
<!-- ── Mode demande ──── --> <!-- ── Mode demande ── -->
<template v-if="requestMode"> <template v-if="requestMode">
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok"> <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 <Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -34,11 +33,11 @@
class="btn-secondary btn-sm" class="btn-secondary btn-sm"
> >
<Clock3 :size="14" :stroke-width="2" /> <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> </button>
</template> </template>
<!-- ── Mode téléchargement (admin) ──────── --> <!-- ── Mode téléchargement (admin) ── -->
<template v-else> <template v-else>
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok"> <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 <Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -80,7 +79,6 @@
</button> </button>
</template> </template>
<!-- Pack saison unique -->
<button <button
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'" v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
class="btn-secondary btn-sm" class="btn-secondary btn-sm"
@@ -91,7 +89,6 @@
{{ seasonBtnLabel }} {{ seasonBtnLabel }}
</button> </button>
<!-- Packs saison multiples -->
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop> <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"> <button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
<Download :size="14" /> <Download :size="14" />
@@ -114,13 +111,12 @@
</div> </div>
</div> </div>
<!-- Désimport de la saison -->
<button <button
v-if="season.organized_count > 0" v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal" @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" 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" title="Retirer la saison de la médiathèque"
aria-label="Désimporter la saison" aria-label="Retirer la saison de la médiathèque"
> >
<Trash2 :size="15" /> <Trash2 :size="15" />
</button> </button>
@@ -128,7 +124,6 @@
</div> </div>
</div> </div>
<!-- Épisodes -->
<div v-if="!collapsed"> <div v-if="!collapsed">
<div <div
v-for="ep in season.episodes" 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" 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"> <div class="flex items-center gap-3 sm:gap-3.5">
<!-- Vignette -->
<div <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="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 }" :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> <span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div> </div>
<!-- Infos épisode -->
<div class="flex-1 min-w-0 flex flex-col gap-[3px]"> <div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<div class="flex items-center gap-2.5 min-w-0"> <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"> <span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
@@ -157,13 +150,13 @@
<span <span
v-if="ep.fankai === false || ep.torrent?.fankai === false" 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" 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> >Hors Fankai</span>
</div> </div>
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span> <span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
</div> </div>
<!-- ── État et actions, mode demande ─────────────────── --> <!-- ── État et actions, mode demande ── -->
<template v-if="requestMode"> <template v-if="requestMode">
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok"> <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> <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" /> <ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
</button> </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"> <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 <button
v-for="(t, i) in (ep.torrents as any[])" v-for="(t, i) in (ep.torrents as any[])"
:key="i" :key="i"
@@ -193,7 +186,7 @@
</div> </div>
</template> </template>
<!-- ── État et actions, admin ────────────────────────── --> <!-- ── État et actions, admin ── -->
<template v-else> <template v-else>
<template v-if="ep.organized"> <template v-if="ep.organized">
<button <button
@@ -246,14 +239,13 @@
@click.stop="openUnimportModal(ep, season)" @click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]" :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" 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" title="Retirer l'épisode de la médiathèque"
aria-label="Désimporter l'épisode" aria-label="Retirer l'épisode de la médiathèque"
> >
<Trash2 :size="14" /> <Trash2 :size="14" />
</button> </button>
</template> </template>
<!-- Synopsis -->
<button <button
v-if="ep.plot" v-if="ep.plot"
@click.stop="togglePlot(ep.id)" @click.stop="togglePlot(ep.id)"
@@ -266,12 +258,10 @@
</button> </button>
</div> </div>
<!-- Progression de l'épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]"> <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 class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div> </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]"> <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 }} {{ parseEpChap(ep.plot).cleanPlot }}
</p> </p>
@@ -279,13 +269,12 @@
</div> </div>
</section> </section>
<!-- Modal désimport épisode -->
<Teleport to="body"> <Teleport to="body">
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false"> <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="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 id="unimport-ep-title" class="card-title">Désimporter l'épisode</h3> <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 bibliothèque.</p> <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> </div>
<label class="flex items-center gap-3 cursor-pointer select-none"> <label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" /> <input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
@@ -294,20 +283,19 @@
<div class="flex gap-2.5 justify-end"> <div class="flex gap-2.5 justify-end">
<button @click="unimportModal = false" class="btn-ghost">Annuler</button> <button @click="unimportModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimport" class="btn-danger"> <button @click="confirmUnimport" class="btn-danger">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }} {{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</Teleport> </Teleport>
<!-- Modal désimport saison -->
<Teleport to="body"> <Teleport to="body">
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false"> <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="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 id="unimport-season-title" class="card-title">Désimporter la saison</h3> <h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
<p class="text-meta text-muted">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p> <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> </div>
<label class="flex items-center gap-3 cursor-pointer select-none"> <label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" /> <input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
@@ -316,7 +304,7 @@
<div class="flex gap-2.5 justify-end"> <div class="flex gap-2.5 justify-end">
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button> <button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSeason" class="btn-danger"> <button @click="confirmUnimportSeason" class="btn-danger">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }} {{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
</button> </button>
</div> </div>
</div> </div>
@@ -327,6 +315,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue' import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next' 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 }[] } interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
@@ -341,9 +330,9 @@ const props = defineProps<{
downloadingSeason ?: boolean downloadingSeason ?: boolean
nfoSupport ?: boolean nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement 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 requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user 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 cet user requestStatus ?: string // statut de la demande en cours de l'utilisateur
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -362,7 +351,6 @@ const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false 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 { function isEpisodeRequested(episodeId: number): boolean {
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false) 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 downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
const checkIcon = h(Check, { 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 { function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent) // Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase() 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 isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) } function isDownloaded(key: string) { return props.downloaded.includes(key) }
function isAlreadyQueued(torrent: any): boolean { function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas : // Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
// 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.
if (torrent?.file_index != null) return false if (torrent?.file_index != null) return false
const hash = extractHash(torrent) const hash = extractHash(torrent)
if (!hash) return false if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase()) 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 availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const hasDownloadable = computed(() => const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) => props.season.episodes.some((ep: any) =>
@@ -414,8 +400,8 @@ const canDownloadSeason = computed(() =>
) )
const seasonBtnLabel = computed(() => { const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importée' if (props.season.organized_state === 'complete') return 'Importée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajoutée' if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée' if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
return 'Télécharger la saison' return 'Télécharger la saison'
}) })
const seasonBtnIcon = computed(() => { const seasonBtnIcon = computed(() => {
@@ -425,7 +411,7 @@ const seasonBtnIcon = computed(() => {
return h(Download, { size: 14 }) 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 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 missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
const downloadableCount = computed(() => const downloadableCount = computed(() =>
@@ -433,36 +419,35 @@ const downloadableCount = computed(() =>
) )
const downloadableLabel = computed(() => { const downloadableLabel = computed(() => {
if (downloadableCount.value === props.season.episodes.length) return 'la saison' 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 // Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
const seasonMeta = computed(() => { const seasonMeta = computed(() => {
const total = props.season.episodes.length const total = props.season.episodes.length
const parts = [`${total} épisode${total > 1 ? 's' : ''}`] const parts = [plural(total, 'épisode')]
if (props.requestMode) { if (props.requestMode) {
const ready = props.season.organized_count ?? 0 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(' · ') return parts.join(' · ')
} }
if (availableCount.value > 0) parts.push(`${availableCount.value} disponible${availableCount.value > 1 ? 's' : ''}`) if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
if (props.season.organized_count > 0) parts.push(`${props.season.organized_count} importé${props.season.organized_count > 1 ? 's' : ''}`) if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`) if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
return parts.join(' · ') 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 { function episodeMeta(ep: any): string {
const { episodes, chapters } = parseEpChap(ep.plot) 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] return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
.filter(Boolean).join(' · ') .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 // Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
function epAnyDownloaded(ep: any): boolean { function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0 const count = ep.torrents?.length ?? 0
@@ -495,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | null {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI) const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) { if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index) 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. // Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
// 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.
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) { if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) } return { ...active, progress: Math.round(file.progress * 100) }
} }
if (!sessionDl) continue if (!sessionDl) continue
} else { } else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress } return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
} }
@@ -517,11 +499,9 @@ function epProgress(ep: any): ActiveTorrent | null {
function handleEpBtnClick(ep: any) { function handleEpBtnClick(ep: any) {
if (ep.organized) return if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) { if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return return
} }
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) { 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) 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) { function handleEpRequestClick(ep: any) {
if (isEpisodeRequested(ep.id)) return if (isEpisodeRequested(ep.id)) return
if (ep.torrents && ep.torrents.length > 1) { if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs torrents → afficher le picker
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return return
} }
// Torrent unique ou aucun → émettre directement
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined) emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
} }
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' { function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
// Vert uniquement quand importé dans la bibliothèque
if (ep.organized) return 'done' 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' if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep) const prog = epProgress(ep)
if (prog) return 'loading' if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable' if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle' return 'idle'
} }
// ── Rename helpers ─────────────────────────────────────────────
// ── Renommage ──
function epExpectedName(ep: any): string { function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)] const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return '' if (!entry) return ''
@@ -556,9 +533,6 @@ function epExpectedName(ep: any): string {
if (props.nfoSupport) { if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else { } 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 usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name ? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
@@ -575,15 +549,10 @@ function epNeedsRename(ep: any): boolean {
return entry.needs_rename === true return entry.needs_rename === true
} }
// ── Extraction épisodes / chapitres depuis le synopsis ───────── // ── Épisodes et chapitres cités dans le synopsis ──
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis) // Cache : le synopsis est analysé plusieurs fois par rendu
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>() const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/** // Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
* 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é.
*/
function _parseEpChap(plot: string | null | undefined): { function _parseEpChap(plot: string | null | undefined): {
episodes: string | null episodes: string | null
season: string | null season: string | null
@@ -637,7 +606,7 @@ function parseEpChap(plot: string | null | undefined) {
return result return result
} }
// ── Détection langue ─────────────────────────────────────────── // ── Langue ──
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null { function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [ const sources: string[] = [
ep.formatted_name ?? '', ep.formatted_name ?? '',
@@ -653,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
return null return null
} }
// ── Labels de groupe ─────────────────────────────────────────── // ── Libellés des versions ──
// 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.
function labelFromRaw(raw: string, index: number): string { function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}` if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw 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 allPresent = names.every(n => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[] if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i)) return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
} }
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite) // Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
// 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.
const uniquePackOptions = computed(() => { const uniquePackOptions = computed(() => {
if (props.season.torrents?.length) return [] if (props.season.torrents?.length) return []
// Compter combien d'épisodes chaque hash couvre
const hashCount = new Map<string, number>() const hashCount = new Map<string, number>()
const hashTorrent = new Map<string, any>() const hashTorrent = new Map<string, any>()
for (const ep of props.season.episodes) { 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()] const packs = [...hashCount.entries()]
.filter(([, count]) => count > 1) .filter(([, count]) => count > 1)
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! })) .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"> <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"> <div class="flex items-baseline gap-3.5">
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1> <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>
<div class="flex items-center gap-2.5 w-full md:w-auto"> <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)" @input="emit('update:search', ($event.target as HTMLInputElement).value)"
@keydown.esc="emit('update:search', '')" @keydown.esc="emit('update:search', '')"
type="text" 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" aria-label="Rechercher une série"
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted" class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
/> />
@@ -39,7 +39,6 @@
</div> </div>
</header> </header>
<!-- Filtres, tri et taille -->
<div ref="rowRef" class="relative"> <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="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"> <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="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'" :class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
:aria-pressed="posterSize === size" :aria-pressed="posterSize === size"
:title="`Affiches ${size}`" :title="`Taille des affiches : ${size}`"
> >
{{ size }} {{ size }}
</button> </button>
@@ -120,6 +119,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next' import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { plural } from '@/utils/format'
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean } export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
@@ -9,8 +9,8 @@
<div class="flex flex-col gap-[7px]"> <div class="flex flex-col gap-[7px]">
<label for="jellyfin-token" class="field-label">Clé API administrateur</label> <label for="jellyfin-token" class="field-label">Clé API administrateur</label>
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off" <input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Tableau de bord → Clés API'" /> :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> <p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
</div> </div>
</div> </div>
@@ -27,6 +27,7 @@
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }} {{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
</span> </span>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</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>
</div> </div>
</template> </template>
@@ -46,6 +47,7 @@ const jellyfinAdminToken = ref('')
const hasToken = ref(false) const hasToken = ref(false)
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
const saveError = ref<string | null>(null)
const testing = ref(false) const testing = ref(false)
const testResult = ref<JellyfinTestResult | null>(null) const testResult = ref<JellyfinTestResult | null>(null)
@@ -64,15 +66,19 @@ onMounted(async () => {
}) })
async function save() { async function save() {
saving.value = true saving.value = true
saved.value = false saved.value = false
saveError.value = null
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value } const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', { const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body), body: JSON.stringify(body),
}) }).catch(() => null)
if (res.ok) { 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() const d = await res.json()
hasToken.value = d.hasToken hasToken.value = d.hasToken
jellyfinAdminToken.value = '' jellyfinAdminToken.value = ''
+26 -28
View File
@@ -3,7 +3,6 @@
<div class="modal-backdrop" @click.self="emit('close')"> <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"> <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 class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div> <div>
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3> <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 === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' : plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' : plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }} '' }}
</p> </p>
</div> </div>
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button> <button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div> </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="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3"> <div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button <button
@@ -30,7 +29,7 @@
</div> </div>
<div> <div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p> <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> </div>
</button> </button>
@@ -43,22 +42,20 @@
</div> </div>
<div> <div>
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p> <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> </div>
</button> </button>
</div> </div>
<!-- OAuth en attente -->
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center"> <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 class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
<div> <div>
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p> <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> </div>
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button> <button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
</div> </div>
<!-- Formulaire credentials -->
<template v-else-if="plexAuthMethod === 'credentials'"> <template v-else-if="plexAuthMethod === 'credentials'">
<div> <div>
<label class="field-label mb-1 block">Adresse e-mail Plex</label> <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" /> <input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div> </div>
<div v-if="plexNeeds2FA"> <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" /> <input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div> </div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p> <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> <button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div> </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"> <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 <div
v-for="(server, i) in plexServers" :key="i" v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)" @click="selectServer(server)"
@@ -96,7 +93,7 @@
> >
<div> <div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p> <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> </div>
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" /> <Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
</div> </div>
@@ -117,7 +114,7 @@
</div> </div>
<div> <div>
<label class="field-label mb-1 block">Chemin de la médiathèque</label> <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" /> <input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
</div> </div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p> <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"> <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>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 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 <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</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>Rafraîchissez les métadonnées</li> <li>Actualisez les métadonnées</li>
</ol> </ol>
</div> </div>
@@ -164,6 +161,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue'
import { Check, Clock3, User, X } from 'lucide-vue-next' import { Check, Clock3, User, X } from 'lucide-vue-next'
import { plural } from '@/utils/format'
const props = defineProps<{ mediaPath: string }>() const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
@@ -190,21 +188,21 @@ const plexForm = ref({
libraryPath: props.mediaPath, libraryPath: props.mediaPath,
}) })
// ── OAuth ───────────────────────────────────────────────────── // ── Connexion via Plex ──
async function startOAuth() { async function startOAuth() {
plexError.value = '' plexError.value = ''
plexLoading.value = true plexLoading.value = true
try { try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' }) const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json() 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) plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth' plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank') window.open(data.authUrl, '_blank')
let attempts = 0 let attempts = 0
plexOAuthTimer = setInterval(async () => { plexOAuthTimer = setInterval(async () => {
attempts++ 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 { try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' }) const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json() const d = await r.json()
@@ -218,7 +216,7 @@ async function startOAuth() {
} catch {} } catch {}
}, 2000) }, 2000)
} catch { } catch {
plexError.value = "Impossible de démarrer l'authentification" plexError.value = "Impossible d'ouvrir la connexion Plex."
} finally { } finally {
plexLoading.value = false plexLoading.value = false
} }
@@ -230,7 +228,7 @@ function cancelOAuth() {
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
} }
// ── Credentials ─────────────────────────────────────────────── // ── Identifiants ──
async function plexConnect() { async function plexConnect() {
plexError.value = '' plexError.value = ''
plexLoading.value = true plexLoading.value = true
@@ -241,8 +239,8 @@ async function plexConnect() {
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' } if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
else plexError.value = data.error ?? 'Erreur de connexion' else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
return return
} }
plexToken.value = data.token plexToken.value = data.token
@@ -250,7 +248,7 @@ async function plexConnect() {
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() } if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server' else plexStep.value = 'server'
} catch { } catch {
plexError.value = 'Impossible de contacter le serveur' plexError.value = 'Impossible de contacter le serveur.'
} finally { } finally {
plexLoading.value = false plexLoading.value = false
} }
@@ -266,11 +264,11 @@ async function plexConnectServer() {
const conns = plexSelectedServer.value.connections ?? [] 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) 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 ?? '' 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 plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library' plexStep.value = 'library'
} catch (err) { } catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur' plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
} finally { } finally {
plexLoading.value = false plexLoading.value = false
} }
@@ -288,9 +286,9 @@ async function plexSetup() {
plexSteps.value = data.steps ?? [] plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done' 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 { } catch {
plexError.value = 'Impossible de contacter le serveur' plexError.value = 'Impossible de contacter le serveur.'
} finally { } finally {
plexLoading.value = false plexLoading.value = false
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <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 <button
v-for="theme in themes" v-for="theme in themes"
:key="theme.id" :key="theme.id"
+8 -10
View File
@@ -15,7 +15,6 @@
</div> </div>
</div> </div>
<!-- Champs principaux -->
<template v-if="currentDefinition"> <template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key"> <div v-for="field in basicFields" :key="field.key">
<SettingsToggle <SettingsToggle
@@ -39,7 +38,6 @@
</div> </div>
</div> </div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0"> <div v-if="advancedFields.length > 0">
<button <button
type="button" type="button"
@@ -92,7 +90,7 @@
<button @click="test" :disabled="testing || !type" class="btn-secondary"> <button @click="test" :disabled="testing || !type" class="btn-secondary">
<Loader v-if="testing" :size="14" class="animate-spin" /> <Loader v-if="testing" :size="14" class="animate-spin" />
<Check v-else-if="tested" :size="14" class="text-ok" /> <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>
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button> <button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
</div> </div>
@@ -122,10 +120,10 @@ const { add: toast } = useToast()
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors'] const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = { 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.', 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, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.', 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 : '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.', 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.', 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 ?? '') const name = ref(props.client?.name ?? '')
@@ -177,7 +175,7 @@ async function test() {
}) })
const { ok, message } = await res.json() const { ok, message } = await res.json()
tested.value = ok 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 { } catch {
toast('Impossible de contacter le serveur', 'error') toast('Impossible de contacter le serveur', 'error')
} finally { } finally {
@@ -199,11 +197,11 @@ async function save() {
}), }),
}) })
if (res.ok) { if (res.ok) {
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success') toast(isEdit ? 'Client modifié' : 'Client enregistré', 'success')
emit('saved', await res.json(), isEdit) emit('saved', await res.json(), isEdit)
} else { } else {
const { error } = await res.json() const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error') toast(error ?? "Impossible d'enregistrer le client", 'error')
} }
} finally { } finally {
saving.value = false saving.value = false
+9 -8
View File
@@ -15,7 +15,7 @@
</span> </span>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium"> <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>
<p class="text-meta text-muted mt-0.5"> <p class="text-meta text-muted mt-0.5">
{{ status.empty {{ status.empty
@@ -24,7 +24,7 @@
</p> </p>
</div> </div>
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0"> <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> </button>
</section> </section>
@@ -35,8 +35,8 @@
<div class="flex-1 min-w-0"> <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-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
<p class="text-meta text-muted mt-0.5"> <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-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 référencer les fichiers présents, sans les déplacer.</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> </p>
</div> </div>
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0"> <button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
@@ -45,7 +45,7 @@
</section> </section>
<p v-if="warnEmpty" class="text-body text-accent" role="alert"> <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> </p>
</template> </template>
@@ -56,6 +56,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { Library, ScanSearch } from 'lucide-vue-next' import { Library, ScanSearch } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import { plural } from '@/utils/format'
import type { SetupSettings } from './setup' import type { SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>() defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
@@ -77,9 +78,9 @@ async function update() {
if (res.ok) { if (res.ok) {
status.value = { exists: true, count: data.count, empty: data.count === 0 } status.value = { exists: true, count: data.count, empty: data.count === 0 }
warnEmpty.value = false warnEmpty.value = false
toast(`${data.count} séries chargées ✓`, 'success') toast(`Catalogue synchronisé : ${plural(data.count, 'série')}`, 'success')
} else { } else {
toast(data.error ?? 'Erreur lors de la mise à jour', 'error') toast(data.error ?? 'Impossible de synchroniser le catalogue', 'error')
} }
} catch { } catch {
toast('Impossible de contacter le serveur', 'error') toast('Impossible de contacter le serveur', 'error')
@@ -96,7 +97,7 @@ async function scan() {
const data = await res.json() const data = await res.json()
scanResult.value = { found: data.found, added: data.added } scanResult.value = { found: data.found, added: data.added }
} else { } else {
toast("Erreur lors de l'analyse", 'error') toast("Impossible d'analyser la médiathèque", 'error')
} }
} catch { } catch {
toast('Impossible de contacter le serveur', 'error') toast('Impossible de contacter le serveur', 'error')
+2 -5
View File
@@ -6,7 +6,6 @@
</div> </div>
<template v-else> <template v-else>
<!-- Clients existants -->
<div v-if="clients.length > 0" class="flex flex-col gap-2"> <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"> <div v-for="client in clients" :key="client.uuid" class="card flex items-center gap-3">
<span <span
@@ -25,7 +24,6 @@
</div> </div>
</div> </div>
<!-- Formulaire -->
<div v-if="formOpen" class="card flex flex-col gap-4"> <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> <p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
<TorrentClientForm <TorrentClientForm
@@ -41,7 +39,6 @@
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }} <Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
</button> </button>
<!-- Rappel chemins -->
<div class="flex items-start gap-3 px-4 py-3 rounded-field border border-border bg-card/50"> <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" /> <Info :size="15" class="text-muted mt-0.5 shrink-0" />
<p class="text-meta text-muted leading-relaxed"> <p class="text-meta text-muted leading-relaxed">
@@ -58,8 +55,8 @@
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<p class="text-body text-accent font-medium">Aucun client configuré</p> <p class="text-body text-accent font-medium">Aucun client configuré</p>
<p class="text-meta text-muted"> <p class="text-meta text-muted">
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même, Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour passer cette étape.
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement. Vous pourrez en ajouter un plus tard dans Paramètres › Clients torrent.
</p> </p>
</div> </div>
</div> </div>
+8 -8
View File
@@ -5,23 +5,23 @@
<SettingsToggle <SettingsToggle
v-model="form.autoImport" v-model="form.autoImport"
label="Import automatique" 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 <SettingsToggle
v-model="form.englishDirectory" v-model="form.englishDirectory"
label="Dossiers « Season » au lieu de « Saison »" label="Dossiers de saison en anglais"
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »." description="« Season 01 » au lieu de « Saison 1 »."
/> />
<SettingsToggle <SettingsToggle
v-model="form.autoUnimportMissing" v-model="form.autoUnimportMissing"
label="Désimporter si le fichier disparaît" label="Retirer les épisodes supprimés du disque"
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant." description="À la prochaine analyse, un épisode dont le fichier a disparu est retiré de la médiathèque."
/> />
<SettingsToggle <SettingsToggle
v-if="settings.organizeMode === 'move'" v-if="settings.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove" v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement" 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> </div>
@@ -29,7 +29,7 @@
<SettingsToggle <SettingsToggle
v-model="form.nfoSupport" v-model="form.nfoSupport"
label="Fichiers NFO et images" 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"> <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" /> <Info :size="13" class="shrink-0 mt-0.5" />
@@ -67,7 +67,7 @@ const error = ref<string | null>(null)
async function submit(): Promise<boolean> { async function submit(): Promise<boolean> {
error.value = null error.value = null
if (await postSettings(form)) return true 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 return false
} }
@@ -1,7 +1,6 @@
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<!-- Jellyfin -->
<section class="card flex flex-col gap-4"> <section class="card flex flex-col gap-4">
<div class="flex items-start gap-3"> <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"> <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> <span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
</p> </p>
<p class="text-meta text-muted mt-0.5 leading-relaxed"> <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> </p>
</div> </div>
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0"> <button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
@@ -31,7 +30,7 @@
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }} {{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button> </button>
<span v-if="syncResult" class="text-meta text-ok"> <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>
<span v-if="syncError" class="text-meta text-err">{{ syncError }}</span> <span v-if="syncError" class="text-meta text-err">{{ syncError }}</span>
</div> </div>
@@ -40,7 +39,6 @@
</template> </template>
</section> </section>
<!-- Plex -->
<section class="card flex flex-col gap-4"> <section class="card flex flex-col gap-4">
<div class="flex items-start gap-3"> <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"> <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>
<p class="text-meta text-muted mt-0.5 leading-relaxed"> <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. 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> </p>
</div> </div>
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary btn-sm shrink-0"> <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> <p v-if="!settings.mediaPath" class="text-meta text-accent">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
</section> </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> </div>
@@ -76,6 +74,7 @@ import { Server, Tv } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue' import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue' import PlexWizard from '@/components/settings/PlexWizard.vue'
import { setupContextKey, type SetupSettings } from './setup' import { setupContextKey, type SetupSettings } from './setup'
import { plural } from '@/utils/format'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>() 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 res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json() const data = await res.json()
if (res.ok) syncResult.value = data 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 { } catch {
syncError.value = 'Impossible de contacter le serveur' syncError.value = 'Impossible de contacter le serveur'
} finally { } finally {
+8 -10
View File
@@ -18,7 +18,7 @@
class="field" class="field"
spellcheck="false" 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> <FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
</button> </button>
</div> </div>
@@ -34,15 +34,14 @@
</div> </div>
<p v-if="result?.relation === 'same'" class="text-meta text-err flex items-start gap-1.5"> <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>
<p v-else-if="result?.relation === 'nested'" class="text-meta text-accent flex items-start gap-1.5"> <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" /> <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> </p>
</div> </div>
<!-- Mode d'import -->
<div class="card flex flex-col gap-3"> <div class="card flex flex-col gap-3">
<label class="field-label">Mode d'import</label> <label class="field-label">Mode d'import</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@@ -62,7 +61,6 @@
</button> </button>
</div> </div>
<!-- Résultat du test hardlink -->
<div v-if="result && form.organizeMode === 'hardlink'" class="text-meta"> <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"> <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 }} <Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
@@ -71,7 +69,7 @@
<p class="text-accent flex items-start gap-1.5"> <p class="text-accent flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }} <TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
</p> </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> <button @click="form.organizeMode = 'copy'" class="btn-secondary btn-sm self-start">Passer en mode Copier</button>
</div> </div>
<p v-else class="text-muted">{{ result.hardlink.message }}</p> <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, key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete', 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, 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 = [ 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: '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({ 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 (!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 ?? '' }))) { 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 false
} }
return true return true
+9 -8
View File
@@ -21,14 +21,14 @@
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary"> <button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary">
<Compass :size="15" /> <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>
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary"> <button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }} {{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
</button> </button>
</div> </div>
<p v-if="!context.relaunch" class="text-meta text-muted -mt-3"> <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> </p>
</div> </div>
@@ -38,6 +38,7 @@
import { ref, reactive, inject, onMounted } from 'vue' import { ref, reactive, inject, onMounted } from 'vue'
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next' import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup' import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
import { plural } from '@/utils/format'
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral' type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
interface Row { label: string; value: string; state: RowState; step: SetupStepId } interface Row { label: string; value: string; state: RowState; step: SetupStepId }
@@ -55,7 +56,7 @@ const stateClass: Record<RowState, string> = {
neutral: 'bg-hover text-muted', 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 }) 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'), mode : row("Mode d'import", 'paths'),
client : row('Client torrent', 'client'), client : row('Client torrent', 'client'),
import : row('Import automatique', 'import', 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'), 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'), 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'), catalog : row('Catalogue Fankai', 'catalog'),
}) })
@@ -90,7 +91,7 @@ async function loadPaths() {
set(rows.paths, 'Médiathèque non configurée', 'error') set(rows.paths, 'Médiathèque non configurée', 'error')
} else { } else {
set(rows.paths, 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') !res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
} }
@@ -139,7 +140,7 @@ async function loadCatalog() {
try { try {
const { count, empty } = await getJson('/api/torrents/status') const { count, empty } = await getJson('/api/torrents/status')
if (empty) set(rows.catalog, 'Catalogue vide', 'warn') 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 { } catch {
set(rows.catalog, 'Impossible de vérifier', 'warn') set(rows.catalog, 'Impossible de vérifier', 'warn')
} }
+5 -8
View File
@@ -1,7 +1,6 @@
<template> <template>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<!-- Fonctionnement -->
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <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"> <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"> <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> </li>
</ol> </ol>
<!-- Ce qu'on va configurer -->
<div class="card flex flex-col gap-3"> <div class="card flex flex-col gap-3">
<p class="field-label">Au programme</p> <p class="field-label">Au programme</p>
<ul class="flex flex-col gap-2"> <ul class="flex flex-col gap-2">
@@ -22,14 +20,13 @@
<Check :size="14" class="text-accent mt-0.5 shrink-0" /> <Check :size="14" class="text-accent mt-0.5 shrink-0" />
<span class="text-secondary"> <span class="text-secondary">
{{ item.label }} {{ 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> </span>
</li> </li>
</ul> </ul>
<p class="text-meta text-muted">Comptez environ 5 minutes.</p> <p class="text-meta text-muted">Comptez environ 5 minutes.</p>
</div> </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"> <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" /> <HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -54,16 +51,16 @@ defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>
const flow = [ const flow = [
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' }, { 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: Download, title: 'Téléchargement', text: 'Le torrent est envoyé à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' }, { 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.' }, { icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
] ]
const checklist = [ 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: 'Client torrent' },
{ label: 'Import automatique et nommage' }, { label: 'Import automatique et nommage' },
{ label: 'Jellyfin ou Plex', optional: true }, { label: 'Jellyfin ou Plex', optional: true },
{ label: 'Téléchargement du catalogue' }, { label: 'Synchronisation du catalogue' },
] ]
</script> </script>
+1 -1
View File
@@ -171,7 +171,7 @@ async function showStep() {
if (el) { if (el) {
const r = el.getBoundingClientRect() const r = el.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) { 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() 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> { export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard) { if (navigator.clipboard) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
return return
} }
// Fallback : execCommand (HTTP non sécurisé) // Hors HTTPS, navigator.clipboard est absent : repli sur execCommand
const el = document.createElement('textarea') const el = document.createElement('textarea')
el.value = text el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0' 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[]>([]) const toasts = ref<Toast[]>([])
let nextId = 0 let nextId = 0
const DURATION = 3500 const DURATION = 3500
const MAX_VISIBLE = 4
function add(message: string, type: Toast['type'] = 'info') { 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 && t.message === message)
const existing = toasts.value.find(t => t.type === type)
if (existing) { if (existing) {
// Grouper — incrémenter le count et mettre à jour le message
clearTimeout(existing.timer) clearTimeout(existing.timer)
existing.count++ existing.count++
existing.message = buildGroupMessage(type, existing.count)
existing.timer = setTimeout(() => remove(existing.id), DURATION) existing.timer = setTimeout(() => remove(existing.id), DURATION)
return return
} }
// Nouveau toast
const id = nextId++ const id = nextId++
const timer = setTimeout(() => remove(id), DURATION) const timer = setTimeout(() => remove(id), DURATION)
toasts.value.push({ id, message, type, count: 1, timer }) toasts.value.push({ id, message, type, count: 1, timer })
} if (toasts.value.length > MAX_VISIBLE) remove(toasts.value[0]!.id)
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`
} }
function remove(id: number) { function remove(id: number) {
-3
View File
@@ -29,7 +29,6 @@ function isNewer(latest: string, current: string): boolean {
async function checkForUpdates() { async function checkForUpdates() {
try { try {
// Version locale
const vRes = await fetch('/api/version', { credentials: 'include' }) const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) { if (vRes.ok) {
const { version } = await vRes.json() const { version } = await vRes.json()
@@ -38,7 +37,6 @@ async function checkForUpdates() {
if (currentVersion.value === 'dev') return if (currentVersion.value === 'dev') return
// Dernière release GitHub
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, { const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' }, headers: { Accept: 'application/vnd.github.v3+json' },
}) })
@@ -57,7 +55,6 @@ export function useUpdateCheck() {
onMounted(() => { onMounted(() => {
if (users++ > 0) return if (users++ > 0) return
checkForUpdates() checkForUpdates()
// Revérifier toutes les 6h
timer = setInterval(checkForUpdates, 6 * 60 * 60 * 1000) 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 let tourAutoStarted = false
watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => { watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
if (tourAutoStarted || tour.active) return if (tourAutoStarted || tour.active) return
@@ -64,7 +64,6 @@ watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
} }
}, { immediate: true }) }, { immediate: true })
// Compteurs de pour la barre latérale
let pendingTimer: ReturnType<typeof setInterval> | null = null let pendingTimer: ReturnType<typeof setInterval> | null = null
let downloadsTimer: ReturnType<typeof setInterval> | null = null let downloadsTimer: ReturnType<typeof setInterval> | null = null
@@ -84,7 +83,6 @@ onUnmounted(() => {
watch(() => route.path, () => reqStore.refreshPending()) watch(() => route.path, () => reqStore.refreshPending())
// Mobile
const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings')) const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings'))
const navItems = computed<NavItem[]>(() => { const navItems = computed<NavItem[]>(() => {
@@ -120,6 +118,5 @@ const navItems = computed<NavItem[]>(() => {
return items return items
}) })
// Mobile 2
const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings'))) const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings')))
</script> </script>
+5 -11
View File
@@ -12,7 +12,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
// ─── Pages publiques hors layout ───────────────────────── // ── Pages hors layout (connexion, invitation, assistant) ──
{ {
path: '/auth', path: '/auth',
component: () => import('@/views/AuthView.vue'), component: () => import('@/views/AuthView.vue'),
@@ -30,7 +30,7 @@ const router = createRouter({
meta: { adminOnly: true }, meta: { adminOnly: true },
}, },
// ─── App principale avec layout sidebar ────────────────── // ── Pages avec barre latérale ──
{ {
path: '/', path: '/',
component: AppLayout, component: AppLayout,
@@ -38,7 +38,6 @@ const router = createRouter({
{ path: '', redirect: '/dashboard' }, { path: '', redirect: '/dashboard' },
{ path: 'downloads', redirect: '/activity' }, { path: 'downloads', redirect: '/activity' },
// Accueil
{ {
path: 'dashboard', path: 'dashboard',
name: 'dashboard', name: 'dashboard',
@@ -64,7 +63,6 @@ const router = createRouter({
component: () => import('@/views/DownloadsView.vue'), component: () => import('@/views/DownloadsView.vue'),
}, },
// Paramètres
{ {
path: 'settings', path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'), component: () => import('@/views/settings/SettingsLayout.vue'),
@@ -98,7 +96,7 @@ const router = createRouter({
{ {
path: 'logs', path: 'logs',
name: 'settings-logs', name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin component: () => import('@/views/settings/LogsView.vue'),
}, },
{ {
path: 'advanced', path: 'advanced',
@@ -120,7 +118,6 @@ const router = createRouter({
], ],
}, },
// Demandes
{ {
path: 'requests', path: 'requests',
name: 'requests', name: 'requests',
@@ -133,25 +130,22 @@ const router = createRouter({
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition if (savedPosition) return savedPosition
if (to.name === 'setup') return { top: 0 } 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 if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 } return { el: '#main-scroll', top: 0 }
}, },
}) })
// ─── Guard auth ─────────────────────────────────────────────── // ── Contrôle d'accès ──
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const auth = useAuthStore() const auth = useAuthStore()
if (auth.loading) await auth.checkStatus() if (auth.loading) await auth.checkStatus()
// Redirige vers /auth si non connecté
if (!to.meta.public && !auth.loggedIn) return '/auth' 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' if (to.path === '/auth' && auth.loggedIn) return '/dashboard'
// Redirige les non-admins hors des pages admin
if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard' if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard'
// Premier lancement : l'assistant de configuration est obligatoire // Premier lancement : l'assistant de configuration est obligatoire
+2 -2
View File
@@ -43,7 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
await checkStatus() await checkStatus()
return null return null
} }
return data.error return data.error ?? 'Impossible de se connecter.'
} }
async function setupAccount(u: string, password: string): Promise<string | null> { async function setupAccount(u: string, password: string): Promise<string | null> {
@@ -63,7 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
tourSeen.value = false tourSeen.value = false
return null return null
} }
return data.error return data.error ?? 'Impossible de créer le compte.'
} }
async function logout() { async function logout() {
+2 -2
View File
@@ -18,8 +18,8 @@ export const useCatalogStore = defineStore('catalog', () => {
error.value = null error.value = null
try { try {
productions.value = await fetchProductions() productions.value = await fetchProductions()
} catch (err) { } catch {
error.value = err instanceof Error ? err.message : 'Erreur inconnue' error.value = 'Impossible de charger le catalogue.'
} finally { } finally {
loading.value = false loading.value = false
} }
+9 -10
View File
@@ -88,18 +88,17 @@ export const useSeriesStore = defineStore('series', () => {
error.value = null error.value = null
try { try {
const res = await fetch('/api/series') 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() const data = await res.json()
series.value = data.series series.value = data.series
// Précache des posters en arrière-plan
for (const s of data.series as Serie[]) { for (const s of data.series as Serie[]) {
if (s.poster_image && !_imageCache.has(s.poster_image)) { if (s.poster_image && !_imageCache.has(s.poster_image)) {
_imageCache.add(s.poster_image) _imageCache.add(s.poster_image)
new Image().src = s.poster_image new Image().src = s.poster_image
} }
} }
} catch (err) { } catch {
error.value = err instanceof Error ? err.message : 'Erreur inconnue' error.value = 'Impossible de contacter le serveur.'
} finally { } finally {
loadingSeries.value = false loadingSeries.value = false
} }
@@ -111,10 +110,10 @@ export const useSeriesStore = defineStore('series', () => {
currentSerie.value = null currentSerie.value = null
try { try {
const res = await fetch(`/api/series/${id}`) 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() currentSerie.value = await res.json()
} catch (err) { } catch {
error.value = err instanceof Error ? err.message : 'Erreur inconnue' error.value = 'Impossible de contacter le serveur.'
} finally { } finally {
loadingDetail.value = false loadingDetail.value = false
} }
@@ -129,9 +128,9 @@ export const useSeriesStore = defineStore('series', () => {
}) })
const data = await res.json() const data = await res.json()
if (res.ok) return { success: true } if (res.ok) return { success: true }
return { success: false, error: data.error } return { success: false, error: data.error ?? "Impossible d'envoyer le torrent au client" }
} catch (err) { } catch {
return { success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' } return { success: false, error: 'Impossible de contacter le serveur' }
} }
} }
+20 -20
View File
@@ -12,58 +12,58 @@ export interface TourStep {
const welcome: TourStep = { const welcome: TourStep = {
id: 'welcome', id: 'welcome',
title: 'Bienvenue dans FanKarr', 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 => ({ const help = (isAdmin: boolean): TourStep => ({
id: 'help', target: ['help'], placement: 'right', id: 'help', target: ['help'], placement: 'right',
title: 'Besoin d\'aide ?', title: "Besoin d'aide ?",
body: isAdmin 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 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 à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.', : "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[] = [ const adminSteps: TourStep[] = [
welcome, welcome,
{ {
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top', id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil', 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', id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'À traiter', 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', id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque', 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', id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres', 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', id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
title: 'Actions groupées', 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', id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
title: 'Demandes', 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', id: 'activity', route: '/activity', target: ['activity-header'], placement: 'bottom',
title: 'Activité', 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', id: 'settings', route: '/settings/download-client', target: ['settings-menu', 'nav-settings'], placement: 'right',
title: 'Paramètres', 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), help(true),
] ]
@@ -71,24 +71,24 @@ const adminSteps: TourStep[] = [
const userSteps: TourStep[] = [ const userSteps: TourStep[] = [
welcome, welcome,
{ {
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top', id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil', title: 'Accueil',
body: 'Les nouveautés de la médiathèque et le nombre de séries prêtes à regarder.', 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', id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'Mes demandes', title: 'Vos dernières demandes',
body: 'L\'état de vos dernières demandes. Juste en dessous, « Pas encore là » propose des séries à demander en un clic.', 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', id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque', 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', id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres', 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', 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', id: 'profile-token', route: '/settings/profile', target: ['profile-token'], placement: 'top',
title: 'Jeton d’API', 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é.', 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), 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: '' } 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 { export function isNew(lastImportedAt: string | null | undefined): boolean {
return !!lastImportedAt && Date.now() - new Date(lastImportedAt).getTime() < 7 * 86_400_000 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