Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04252db0c4 | ||
|
|
aeb2e938ef | ||
|
|
3799d33c62 | ||
|
|
98b317d493 | ||
|
|
4292f96645 | ||
|
|
182c7cc807 | ||
|
|
53be057425 | ||
|
|
ac70dba927 | ||
|
|
f79cc244ef | ||
|
|
14137e1b1b | ||
|
|
43ee4597a9 | ||
|
|
f311c8e2f3 | ||
|
|
48933b4078 | ||
|
|
45288a130f | ||
|
|
73256eb15d | ||
|
|
6855c04fab | ||
|
|
f6f735ad88 | ||
|
|
a7250a5f80 | ||
|
|
d326e08834 | ||
|
|
0e2d513cf7 | ||
|
|
5cf530ba78 | ||
|
|
3bc2e08ca5 | ||
|
|
38996b4095 | ||
|
|
4063a1b8ae | ||
|
|
e9e0b487f4 | ||
|
|
099b0889b0 | ||
|
|
33be4ce687 | ||
|
|
5c7b3dc5cd | ||
|
|
f909f554ff | ||
|
|
fc6d88be5e | ||
|
|
ed556acd1d | ||
|
|
c6c52a1ae7 | ||
|
|
98ed3c7199 | ||
|
|
ee1c7d579c | ||
|
|
ba06dce34d | ||
|
|
8991524463 | ||
|
|
3fe1006a1e | ||
|
|
63c8f8a841 | ||
|
|
25877685ea | ||
|
|
5b111272d4 | ||
|
|
67365336bb | ||
|
|
3a991c8fcf | ||
|
|
d85708588c | ||
|
|
63f6c1276b | ||
|
|
8b4d5e20a9 | ||
|
|
9b22b1f377 | ||
|
|
d1357e7a79 | ||
|
|
e15e7beb1b | ||
|
|
8b4b198697 | ||
|
|
13700c554a | ||
|
|
1e6506aee6 | ||
|
|
2235541000 | ||
|
|
6f5b9e2ee4 | ||
|
|
045213d363 | ||
|
|
1f0c9a338a | ||
|
|
59b40cfd59 | ||
|
|
fc4598a810 | ||
|
|
7b1863180f | ||
|
|
edec048e30 | ||
|
|
4690874be1 | ||
|
|
bf9b18ec00 | ||
|
|
d08d6e6032 |
No files matched your search
@@ -1,5 +1,99 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.20.2](https://github.com/Masutayunikon/FanKarr/compare/v3.20.1...v3.20.2) (2026-06-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** add placeholder from config ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
* **client:** remove alldebrid ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
* **label:** add season of episodes ([98b317d](https://github.com/Masutayunikon/FanKarr/commit/98b317d493b7742c330d49ca816c0fecd983e82a))
|
||||||
|
* **scraper:** fallback on empty env for GITHUB_BASE ([3799d33](https://github.com/Masutayunikon/FanKarr/commit/3799d33c625fe28eab4b46917ff32b1266cda1e3))
|
||||||
|
|
||||||
|
## [3.20.1](https://github.com/Masutayunikon/FanKarr/compare/v3.20.0...v3.20.1) (2026-06-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **organize:** import manuel ne traite que les fichiers 100% complets ([f311c8e](https://github.com/Masutayunikon/FanKarr/commit/f311c8e2f3b9f53b6d03a327cf1b5bfd69cc6424))
|
||||||
|
|
||||||
|
## [3.20.0](https://github.com/Masutayunikon/FanKarr/compare/v3.19.1...v3.20.0) (2026-05-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **torrent:** système générique de suivi des tâches gérées ([a7250a5](https://github.com/Masutayunikon/FanKarr/commit/a7250a5f804189a7ddecef44193436cf6b324c31))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **organize:** fallback par torrent_name quand le hash n'est pas résolu ([f6f735a](https://github.com/Masutayunikon/FanKarr/commit/f6f735ad8838d0755472f739571684d75f9dc1b5))
|
||||||
|
* **synology:** filtrage par savePath et retour au GET pour add() ([d326e08](https://github.com/Masutayunikon/FanKarr/commit/d326e0883477a7dbf453ab28a3fc619f759a8604))
|
||||||
|
|
||||||
|
## [3.19.1](https://github.com/Masutayunikon/FanKarr/compare/v3.19.0...v3.19.1) (2026-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** rerun deployment ([099b088](https://github.com/Masutayunikon/FanKarr/commit/099b0889b069f0226b7cb2ec4fa3b1ca535a82a6))
|
||||||
|
|
||||||
|
## [3.19.0](https://github.com/Masutayunikon/FanKarr/compare/v3.18.3...v3.19.0) (2026-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **synology:** files par fichier + getFiles() comme qbittorrent ([c6c52a1](https://github.com/Masutayunikon/FanKarr/commit/c6c52a1ae742dcb02ee86f3de04831ac1f041940))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **organize:** markOrganized manquant en mode copy pour les packs ([98ed3c7](https://github.com/Masutayunikon/FanKarr/commit/98ed3c7199915d2aafbb7be87b41fb725bb0c1ee))
|
||||||
|
* **synology:** resolution hash via cache URI + magnet + detail.hash ([ee1c7d5](https://github.com/Masutayunikon/FanKarr/commit/ee1c7d579c3795701158e39159511be3cc1789d7))
|
||||||
|
* **torrent:** correction suppression torrent après déplacement ([ed556ac](https://github.com/Masutayunikon/FanKarr/commit/ed556acd1d2990a6375d4a60f745048a79854249))
|
||||||
|
|
||||||
|
## [3.18.3](https://github.com/Masutayunikon/FanKarr/compare/v3.18.2...v3.18.3) (2026-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **synology:** double prefix dbid_ et fallback hash ([2587768](https://github.com/Masutayunikon/FanKarr/commit/25877685eaf02b3b1c02ff06db618a37aebf0e6b))
|
||||||
|
|
||||||
|
## [3.18.2](https://github.com/Masutayunikon/FanKarr/compare/v3.18.1...v3.18.2) (2026-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** synology use default savepath when 403 ([63f6c12](https://github.com/Masutayunikon/FanKarr/commit/63f6c1276bd48a14e5806ff997b6be63c458d94c))
|
||||||
|
|
||||||
|
## [3.18.1](https://github.com/Masutayunikon/FanKarr/compare/v3.18.0...v3.18.1) (2026-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** suppresion de alldebrid ([8b4b198](https://github.com/Masutayunikon/FanKarr/commit/8b4b198697ba3296444759c522bb0139be69e364))
|
||||||
|
|
||||||
|
## [3.18.0](https://github.com/Masutayunikon/FanKarr/compare/v3.17.1...v3.18.0) (2026-05-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **client:** ajout deluge ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** alldebrid ([045213d](https://github.com/Masutayunikon/FanKarr/commit/045213d363a75bf56bdb5a05e3965a040ead2893))
|
||||||
|
|
||||||
|
## [3.17.1](https://github.com/Masutayunikon/FanKarr/compare/v3.17.0...v3.17.1) (2026-05-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** la recherche ne retourne plus les series sans torrents ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
|
||||||
|
* **client:** alldebrid utilise maintenant les routes 4.1 ([7b18631](https://github.com/Masutayunikon/FanKarr/commit/7b1863180f28cfaaccff69cb949fa6116a2c8cc6))
|
||||||
|
* **readme:** update ([edec048](https://github.com/Masutayunikon/FanKarr/commit/edec048e30e1a5a528f1b2888f3db1708eeb15f9))
|
||||||
|
* **readme:** update ([bf9b18e](https://github.com/Masutayunikon/FanKarr/commit/bf9b18ec0002e84b040738f7229ed4570f5dcd32))
|
||||||
|
|
||||||
## [3.17.0](https://github.com/Masutayunikon/FanKarr/compare/v3.16.0...v3.17.0) (2026-05-23)
|
## [3.17.0](https://github.com/Masutayunikon/FanKarr/compare/v3.16.0...v3.17.0) (2026-05-23)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,22 +50,37 @@ Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
|||||||
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
|
- **Dropdown intelligent** sur chaque bouton quand plusieurs sources sont disponibles (qualités différentes, packs différents)
|
||||||
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
|
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
|
||||||
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
|
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
|
||||||
|
- **RSS Sync** — surveillance automatique des nouvelles sorties toutes les 6 heures pour les séries abonnées, téléchargement sans intervention
|
||||||
|
|
||||||
### 🗂️ 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 fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
|
||||||
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
|
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
|
||||||
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
|
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
|
||||||
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
|
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
|
||||||
|
- **Suppression du dossier série** automatique lors d'un désimport complet avec suppression des fichiers
|
||||||
|
|
||||||
|
### 📋 Demandes
|
||||||
|
- **Système de demandes** — les utilisateurs peuvent demander des séries, saisons ou épisodes spécifiques depuis l'interface ou le plugin Jellyfin
|
||||||
|
- **Workflow admin** — approbation / refus avec message / marquage disponible depuis la page dédiée
|
||||||
|
- **Auto-téléchargement** configurable par utilisateur — les demandes sont approuvées et lancées automatiquement pour les utilisateurs autorisés
|
||||||
|
- **Suppression en masse** pour les admins (bouton "Tout supprimer")
|
||||||
|
- **Nettoyage automatique** — les demandes marquées "disponible" sont supprimées lors du désimport de la série concernée
|
||||||
|
|
||||||
### 🏷️ Métadonnées & Affichage
|
### 🏷️ Métadonnées & Affichage
|
||||||
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
|
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
|
||||||
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
|
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
|
||||||
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
|
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
|
||||||
|
|
||||||
|
### 👥 Utilisateurs & Jellyfin
|
||||||
|
- **Multi-utilisateurs** avec rôles (`admin` / `user`) et système d'invitations
|
||||||
|
- **SSO Jellyfin** — les utilisateurs se connectent avec leur compte Jellyfin, aucun mot de passe FanKarr requis
|
||||||
|
- **Synchronisation Jellyfin** — création automatique des comptes FanKarr pour les utilisateurs Jellyfin, toutes les heures en arrière-plan ou manuellement depuis les paramètres
|
||||||
|
- **Token API personnel** — chaque utilisateur dispose d'un token pour accéder à l'API publique
|
||||||
|
|
||||||
### ⚙️ Système
|
### ⚙️ Système
|
||||||
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
|
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
|
||||||
- **Authentification** par mot de passe avec session JWT
|
- **Authentification** par mot de passe avec session JWT
|
||||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid
|
||||||
- **Compatible Docker / Runtipi / Binaire autonome**
|
- **Compatible Docker / Runtipi / Binaire autonome**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -222,6 +237,117 @@ Le **scan médiathèque** permet de référencer des fichiers déjà présents s
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
👉 **[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.
|
||||||
|
|
||||||
|
### Synchronisation des utilisateurs
|
||||||
|
|
||||||
|
FanKarr peut créer automatiquement un compte pour chaque utilisateur Jellyfin actif. La synchronisation se fait :
|
||||||
|
- **Automatiquement** toutes les heures en arrière-plan (si Jellyfin est configuré)
|
||||||
|
- **Manuellement** depuis **Paramètres → Jellyfin → Synchroniser**
|
||||||
|
|
||||||
|
Les comptes créés n'ont pas de mot de passe FanKarr — l'authentification se fait uniquement via SSO Jellyfin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Publique
|
||||||
|
|
||||||
|
FanKarr expose une API publique `v1` utilisée par le plugin Jellyfin et toute intégration tierce.
|
||||||
|
|
||||||
|
**Auth** : `Authorization: Bearer <token>` (token visible dans **Profil → Token API**)
|
||||||
|
|
||||||
|
| Méthode | Endpoint | Description |
|
||||||
|
|---------|----------|-------------|
|
||||||
|
| `POST` | `/api/v1/auth/jellyfin` | Échange un token Jellyfin contre un token FanKarr |
|
||||||
|
| `GET` | `/api/v1/auth/me` | Informations de l'utilisateur connecté |
|
||||||
|
| `GET` | `/api/v1/series/search?q=` | Recherche dans le catalogue (titre, note, année, description, demande en cours) |
|
||||||
|
| `GET` | `/api/v1/series/:id` | Détail d'une série — saisons et épisodes (proxy FanKai) |
|
||||||
|
| `POST` | `/api/v1/requests` | Créer ou mettre à jour une demande |
|
||||||
|
| `GET` | `/api/v1/requests` | Demandes de l'utilisateur authentifié |
|
||||||
|
|
||||||
|
### SSO Jellyfin
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/auth/jellyfin
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "jellyfinUserId": "...", "jellyfinToken": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Retourne `{ token, username, role }`. Le `token` est ensuite utilisé comme Bearer pour tous les autres appels.
|
||||||
|
|
||||||
|
### Recherche
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/series/search?q=dragon+ball
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "Dragon Ball Z Kai",
|
||||||
|
"original_title": "DRAGON BALL Z KAI",
|
||||||
|
"image": "https://...",
|
||||||
|
"year": 2009,
|
||||||
|
"rating": 8.5,
|
||||||
|
"description": "Suite de Dragon Ball...",
|
||||||
|
"request": {
|
||||||
|
"id": "uuid",
|
||||||
|
"status": "pending",
|
||||||
|
"seasons": [1, 2],
|
||||||
|
"episodes": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le champ `request` est `null` si aucune demande active n'existe pour cette série.
|
||||||
|
|
||||||
|
### Détail série (saisons & épisodes)
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/series/42
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"title": "Dragon Ball Z Kai",
|
||||||
|
"seasons": [
|
||||||
|
{
|
||||||
|
"season_number": 1,
|
||||||
|
"episodes": [
|
||||||
|
{ "id": 101, "episode_number": 1, "title": "...", "image": "https://..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer une demande
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/requests
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "serieId": 42, "serieName": "Dragon Ball Z Kai", "seasons": [1, 2] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `seasons` : numéros de saison (`season_number`). Tableau vide = toutes les saisons.
|
||||||
|
- `episodes` : IDs d'épisodes (`episode.id`). Prend le dessus sur `seasons` si renseigné.
|
||||||
|
- Si une demande active existe déjà, les saisons/épisodes sont **fusionnés**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Runtipi
|
## Runtipi
|
||||||
|
|
||||||
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||||
@@ -248,6 +374,7 @@ FanKarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
|||||||
## 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 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 dans Jellyfin
|
||||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
||||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 231 KiB |
+6
-6
@@ -13,7 +13,7 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
|
|||||||
import utorrentDriver from './torrent-clients/utorrent.js'
|
import utorrentDriver from './torrent-clients/utorrent.js'
|
||||||
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
import rtorrentDriver from './torrent-clients/rtorrent.js'
|
||||||
import realDebridDriver from './torrent-clients/real-debrid.js'
|
import realDebridDriver from './torrent-clients/real-debrid.js'
|
||||||
import allDebridDriver from './torrent-clients/alldebrid.js'
|
import delugeDriver from './torrent-clients/deluge.js'
|
||||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
|
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
|
||||||
import { logger } from './logger.js'
|
import { logger } from './logger.js'
|
||||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||||
@@ -47,7 +47,7 @@ registerDriver(synologyDsDriver)
|
|||||||
registerDriver(utorrentDriver)
|
registerDriver(utorrentDriver)
|
||||||
registerDriver(rtorrentDriver)
|
registerDriver(rtorrentDriver)
|
||||||
registerDriver(realDebridDriver)
|
registerDriver(realDebridDriver)
|
||||||
registerDriver(allDebridDriver)
|
registerDriver(delugeDriver)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT) || 9898
|
const PORT = Number(process.env.PORT) || 9898
|
||||||
@@ -210,11 +210,11 @@ server.listen(PORT, async () => {
|
|||||||
}
|
}
|
||||||
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
const { organizeMode, deleteTorrentOnMove } = readSettings()
|
||||||
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
|
||||||
try {
|
const removeResult = await dispatchRemove(result.hash, false)
|
||||||
await dispatchRemove(result.hash, false)
|
if (removeResult.ok) {
|
||||||
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
logger.info('api', `Torrent "${result.name}" supprimé après move`)
|
||||||
} catch (err) {
|
} else {
|
||||||
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
|
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { DATA_DIR } from '../config.js'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
const GITHUB_BASE =
|
||||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
process.env.GITHUB_BASE?.trim() ||
|
||||||
|
'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||||
|
|
||||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ function isOrganized(hash: string, episodeId: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lookup torrent dans les seriesData ───────────────────────
|
// ─── Lookup torrent dans les seriesData ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
@@ -101,6 +105,53 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback quand le hash n'est pas résolu (ex: dbid_X Synology).
|
||||||
|
* Cherche par torrent_name (nom réel du dossier torrent) ou title dans le catalogue.
|
||||||
|
* Retourne aussi le vrai infohash BT pour que l'import soit correctement indexé.
|
||||||
|
*/
|
||||||
|
function findTorrentByName(
|
||||||
|
name : string,
|
||||||
|
seriesData: any[],
|
||||||
|
): { torrent: any; serieData: any; infohash: string } | null {
|
||||||
|
const n = name.toLowerCase().trim()
|
||||||
|
if (!n) return null
|
||||||
|
|
||||||
|
for (const sd of seriesData) {
|
||||||
|
// Pack intégral (torrents au niveau série)
|
||||||
|
for (const t of sd.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: t, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const season of sd.seasons ?? []) {
|
||||||
|
// Pack saison
|
||||||
|
for (const t of season.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: { ...t, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Torrent épisode unique
|
||||||
|
for (const ep of season.episodes ?? []) {
|
||||||
|
for (const t of ep.torrents ?? []) {
|
||||||
|
if (!t.infohash) continue
|
||||||
|
if (
|
||||||
|
t.torrent_name?.toLowerCase().trim() === n ||
|
||||||
|
t.title?.toLowerCase().trim() === n
|
||||||
|
) return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd, infohash: t.infohash.toLowerCase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function buildFileMap(
|
function buildFileMap(
|
||||||
serieData : any,
|
serieData : any,
|
||||||
hash : string,
|
hash : string,
|
||||||
@@ -458,7 +509,12 @@ async function organizeTorrent(
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(destDir, { recursive: true })
|
fs.mkdirSync(destDir, { recursive: true })
|
||||||
if (organizeMode === 'copy') {
|
if (organizeMode === 'copy') {
|
||||||
await fsp.copyFile(src, dest)
|
await fsp.copyFile(src, dest)
|
||||||
|
markOrganized(hash, episode_id, {
|
||||||
|
at: new Date().toISOString(), season: season_number,
|
||||||
|
episode: episode_number, episode_id,
|
||||||
|
src_filename: filename, dest_filename: nfo_filename, dest_path: dest, dest_dir: destDir,
|
||||||
|
})
|
||||||
} else if (organizeMode === 'hardlink') {
|
} else if (organizeMode === 'hardlink') {
|
||||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||||
markOrganized(hash, episode_id, {
|
markOrganized(hash, episode_id, {
|
||||||
@@ -499,7 +555,22 @@ 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
|
||||||
|
|
||||||
const found = findTorrentByHash(t.hash, seriesData)
|
// Résolution principale : par infohash
|
||||||
|
let found = findTorrentByHash(t.hash, seriesData)
|
||||||
|
let effectiveHash = t.hash
|
||||||
|
|
||||||
|
// Fallback : si le hash n'est pas résolu (ex: dbid_X Synology), on cherche
|
||||||
|
// par torrent_name — le nom du dossier racine tel que rapporté par le client,
|
||||||
|
// qui correspond au champ torrent_name dans les données série.
|
||||||
|
if (!found && t.name) {
|
||||||
|
const byName = findTorrentByName(t.name, seriesData)
|
||||||
|
if (byName) {
|
||||||
|
found = byName
|
||||||
|
effectiveHash = byName.infohash
|
||||||
|
debug(`Hash résolu par torrent_name : "${t.name}" → ${effectiveHash.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||||
continue
|
continue
|
||||||
@@ -507,8 +578,8 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
|
|
||||||
const { torrent, serieData } = found
|
const { torrent, serieData } = found
|
||||||
const seasonFilter = torrent._season?.season_number
|
const seasonFilter = torrent._season?.season_number
|
||||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
const fileMap = buildFileMap(serieData, effectiveHash, nfoSupport, seasonFilter)
|
||||||
const orgHash = organized[t.hash] ?? {}
|
const orgHash = organized[effectiveHash] ?? {}
|
||||||
const allDone = fileMap.size > 0
|
const allDone = fileMap.size > 0
|
||||||
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
|
||||||
: Object.keys(orgHash).length > 0
|
: Object.keys(orgHash).length > 0
|
||||||
@@ -541,8 +612,8 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
|
const result = await organizeTorrent(effectiveHash, t.name, t.save_path, seriesData, completedFileNames)
|
||||||
parentPort?.postMessage({ type: 'result', hash: t.hash, 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(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -520,13 +520,17 @@ export async function organizeTorrent(
|
|||||||
hash : string,
|
hash : string,
|
||||||
name : string,
|
name : string,
|
||||||
savePath : string,
|
savePath : string,
|
||||||
seriesData: any[]
|
seriesData: any[],
|
||||||
|
files? : { name?: string; progress: number; priority?: number }[],
|
||||||
): Promise<OrganizeResult> {
|
): Promise<OrganizeResult> {
|
||||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(resolveWorkerPath())
|
const worker = new Worker(resolveWorkerPath())
|
||||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
// files : progression par fichier récupérée depuis le client torrent.
|
||||||
|
// Permet au worker de ne traiter que les fichiers à 100% et d'éviter
|
||||||
|
// les EBUSY sur Windows quand qBit est encore en train d'écrire les autres.
|
||||||
|
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: [] }
|
||||||
|
|
||||||
worker.on('message', (msg: any) => {
|
worker.on('message', (msg: any) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from '../logger.js'
|
|||||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||||
|
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||||
import { DATA_DIR } from '../config.js'
|
import { DATA_DIR } from '../config.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -36,7 +37,13 @@ router.post('/organize', requireAuth, async (req, res) => {
|
|||||||
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, name et save_path requis' }); return }
|
||||||
try {
|
try {
|
||||||
const seriesData = await loadEnrichedSeriesData()
|
const seriesData = await loadEnrichedSeriesData()
|
||||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
|
||||||
|
// Récupère la progression par fichier depuis le client torrent.
|
||||||
|
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
|
||||||
|
// En cas d'échec (client injoignable, hash non trouvé), on continue sans filtre.
|
||||||
|
const files = await dispatchGetFiles(hash).catch(() => [])
|
||||||
|
|
||||||
|
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||||
if (result.done > 0 || result.errors.length > 0) {
|
if (result.done > 0 || result.errors.length > 0) {
|
||||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = all
|
const results = all
|
||||||
|
.filter(s => availableSet.has(s.id))
|
||||||
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
|
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
|
||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
@@ -104,7 +105,6 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
|
|||||||
title : s.title,
|
title : s.title,
|
||||||
original_title: s.original_title ?? null,
|
original_title: s.original_title ?? null,
|
||||||
image : s.poster_image ?? null,
|
image : s.poster_image ?? null,
|
||||||
available : availableSet.has(s.id),
|
|
||||||
year : s.year ?? null,
|
year : s.year ?? null,
|
||||||
rating : s.rating?.value ?? null,
|
rating : s.rating?.value ?? null,
|
||||||
description : s.plot ?? null,
|
description : s.plot ?? null,
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
|
|
||||||
import { logger } from '../logger.js'
|
|
||||||
|
|
||||||
const AD_BASE = 'https://api.alldebrid.com/v4'
|
|
||||||
const AD_AGENT = 'FanKarr'
|
|
||||||
|
|
||||||
function adUrl(config: Record<string, string | number>, path: string): string {
|
|
||||||
const sep = path.includes('?') ? '&' : '?'
|
|
||||||
return `${AD_BASE}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function adFetch(
|
|
||||||
config: Record<string, string | number>,
|
|
||||||
method: 'GET' | 'POST' | 'DELETE',
|
|
||||||
path : string,
|
|
||||||
body ?: URLSearchParams | FormData,
|
|
||||||
): Promise<any> {
|
|
||||||
const res = await fetch(adUrl(config, path), { method, body })
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '')
|
|
||||||
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
|
|
||||||
}
|
|
||||||
const json = await res.json()
|
|
||||||
if (json.status === 'error') {
|
|
||||||
throw new Error(`AllDebrid : ${json.error?.message ?? json.error?.code ?? 'Erreur inconnue'}`)
|
|
||||||
}
|
|
||||||
return json.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusCode : 0=Processing, 1=Downloading, 2=Compressing, 3=Uploading, 4=Ready,
|
|
||||||
// 5=Upload Fail, 6=Internal Error, 7=Not Downloaded, 8=File Not Available,
|
|
||||||
// 9=Banned BitTorrent, 10=Awaiting Slot, 11=Free Trial Limit Reached
|
|
||||||
function mapStatus(statusCode: number): TorrentInfo['state'] {
|
|
||||||
switch (statusCode) {
|
|
||||||
case 4: return 'seeding'
|
|
||||||
case 1:
|
|
||||||
case 0:
|
|
||||||
case 2:
|
|
||||||
case 3:
|
|
||||||
case 10: return 'downloading'
|
|
||||||
case 5:
|
|
||||||
case 6:
|
|
||||||
case 7:
|
|
||||||
case 8:
|
|
||||||
case 9:
|
|
||||||
case 11: return 'error'
|
|
||||||
default: return 'unknown'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenFiles(nodes: any[], prefix = '', startIdx = 0): TorrentFileProgress[] {
|
|
||||||
const result: TorrentFileProgress[] = []
|
|
||||||
let idx = startIdx
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.e) {
|
|
||||||
const children = flattenFiles(node.e, prefix ? `${prefix}/${node.n}` : node.n, idx)
|
|
||||||
result.push(...children)
|
|
||||||
idx += children.length
|
|
||||||
} else if (node.n) {
|
|
||||||
result.push({ index: idx++, name: prefix ? `${prefix}/${node.n}` : node.n, progress: 0, priority: 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const adDriver: TorrentClientDriver = {
|
|
||||||
definition: {
|
|
||||||
id : 'alldebrid',
|
|
||||||
label : 'AllDebrid',
|
|
||||||
fields: [
|
|
||||||
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
|
|
||||||
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/alldebrid', required: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
async test(config) {
|
|
||||||
try {
|
|
||||||
const data = await adFetch(config, 'GET', '/user')
|
|
||||||
const username = data.user?.username ?? data.user?.login ?? '?'
|
|
||||||
logger.info('alldebrid', `Connexion réussie — utilisateur : ${username}`)
|
|
||||||
return { ok: true, message: `Connecté en tant que ${username}` }
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
|
||||||
logger.warn('alldebrid', `Test de connexion échoué : ${msg}`)
|
|
||||||
return { ok: false, message: msg }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async healthcheck(config) {
|
|
||||||
try {
|
|
||||||
const data = await adFetch(config, 'GET', '/user')
|
|
||||||
const username = data.user?.username ?? data.user?.login ?? '?'
|
|
||||||
return { online: true, version: `AD — ${username}` }
|
|
||||||
} catch {
|
|
||||||
return { online: false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async add(config, url, options?: DownloadOptions) {
|
|
||||||
if (options?.file_index != null)
|
|
||||||
logger.warn('alldebrid', `Sélection de fichier (index ${options.file_index}) non supportée par AllDebrid — pack complet téléchargé`)
|
|
||||||
|
|
||||||
const isMagnet = url.startsWith('magnet:')
|
|
||||||
const hash = (options?.infohash?.toLowerCase() ?? null)
|
|
||||||
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|
|
||||||
|| null
|
|
||||||
|
|
||||||
if (hash) {
|
|
||||||
try {
|
|
||||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
|
||||||
const magnets: any[] = data.magnets ?? []
|
|
||||||
const existing = magnets.find((m: any) => m.hash?.toLowerCase() === hash)
|
|
||||||
if (existing) {
|
|
||||||
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur AllDebrid`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMagnet) {
|
|
||||||
const body = new URLSearchParams()
|
|
||||||
body.append('magnets[]', url)
|
|
||||||
const data = await adFetch(config, 'POST', '/magnet/upload', body)
|
|
||||||
const id = data.magnets?.[0]?.id
|
|
||||||
logger.info('alldebrid', `Magnet ajouté → ID ${id}`)
|
|
||||||
} else {
|
|
||||||
const torrentRes = await fetch(url)
|
|
||||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
|
|
||||||
const torrentBuf = await torrentRes.arrayBuffer()
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('files[]', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
|
||||||
const data = await adFetch(config, 'POST', '/magnet/upload/file', formData)
|
|
||||||
const id = data.magnets?.[0]?.id
|
|
||||||
logger.info('alldebrid', `.torrent uploadé → ID ${id}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async list(config, _category?) {
|
|
||||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
|
||||||
const magnets: any[] = data.magnets ?? []
|
|
||||||
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
|
|
||||||
|
|
||||||
return magnets.map((m: any): TorrentInfo => {
|
|
||||||
const statusCode = typeof m.statusCode === 'number' ? m.statusCode : 0
|
|
||||||
const size = typeof m.size === 'number' ? m.size : 0
|
|
||||||
const downloaded = typeof m.downloaded === 'number' ? m.downloaded : 0
|
|
||||||
const progress = size > 0
|
|
||||||
? Math.round((downloaded / size) * 100)
|
|
||||||
: (statusCode === 4 ? 100 : 0)
|
|
||||||
const save_path = localBase ? `${localBase}/${m.filename ?? m.id}` : ''
|
|
||||||
|
|
||||||
return {
|
|
||||||
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
|
|
||||||
name : String(m.filename ?? m.id ?? ''),
|
|
||||||
state : mapStatus(statusCode),
|
|
||||||
progress,
|
|
||||||
size,
|
|
||||||
downloaded,
|
|
||||||
uploaded : 0,
|
|
||||||
ratio : 0,
|
|
||||||
speed : typeof m.speed === 'number' ? m.speed : 0,
|
|
||||||
upspeed : 0,
|
|
||||||
eta : typeof m.eta === 'number' ? m.eta : -1,
|
|
||||||
save_path,
|
|
||||||
category : '',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async remove(config, hash, _deleteFiles?) {
|
|
||||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
|
||||||
const magnets: any[] = data.magnets ?? []
|
|
||||||
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
|
|
||||||
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur AllDebrid`)
|
|
||||||
await adFetch(config, 'DELETE', `/magnet/delete?id=${target.id}`)
|
|
||||||
logger.info('alldebrid', `Torrent ${hash.slice(0, 8)}… supprimé de AllDebrid`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getFiles(config, hash) {
|
|
||||||
const data = await adFetch(config, 'GET', '/magnet/status')
|
|
||||||
const magnets: any[] = data.magnets ?? []
|
|
||||||
const target = magnets.find((m: any) => String(m.hash ?? m.id ?? '').toLowerCase() === hash.toLowerCase())
|
|
||||||
if (!target) return []
|
|
||||||
|
|
||||||
const detail = await adFetch(config, 'GET', `/magnet/status?id=${target.id}`)
|
|
||||||
const magnet = Array.isArray(detail.magnets) ? detail.magnets[0] : detail
|
|
||||||
|
|
||||||
const statusCode = typeof magnet.statusCode === 'number' ? magnet.statusCode : 0
|
|
||||||
const size = typeof magnet.size === 'number' ? magnet.size : 0
|
|
||||||
const downloaded = typeof magnet.downloaded === 'number' ? magnet.downloaded : 0
|
|
||||||
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
|
|
||||||
|
|
||||||
if (!Array.isArray(magnet.files)) return []
|
|
||||||
|
|
||||||
return flattenFiles(magnet.files).map(f => ({ ...f, progress: overallProgress }))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default adDriver
|
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from './index.js'
|
||||||
|
import { logger } from '../logger.js'
|
||||||
|
|
||||||
|
// ─── Session cache ─────────────────────────────────────────────────────────────
|
||||||
|
// Chaque entrée est indexée par "url::password" pour supporter plusieurs instances.
|
||||||
|
// TTL 30 min — Deluge invalide les sessions inactives, on relogine automatiquement.
|
||||||
|
|
||||||
|
interface SessionEntry { cookie: string; expires: number }
|
||||||
|
const _sessions = new Map<string, SessionEntry>()
|
||||||
|
|
||||||
|
function sessionKey(config: Record<string, string | number>): string {
|
||||||
|
return `${config.url}::${config.password}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers HTTP ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function delugeUrl(config: Record<string, string | number>): string {
|
||||||
|
return String(config.url ?? '').replace(/\/+$/, '') + '/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookie(raw: string): string {
|
||||||
|
// "set-cookie: _session_id=abc123; Path=/; HttpOnly" → "_session_id=abc123"
|
||||||
|
return raw.split(';')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delugeRPC(
|
||||||
|
url : string,
|
||||||
|
method : string,
|
||||||
|
params : unknown[],
|
||||||
|
cookie : string,
|
||||||
|
): Promise<{ result: any; newCookie: string | null }> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept' : 'application/json',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ method, params, id: 1 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.error) {
|
||||||
|
throw new Error(`Deluge RPC : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCookie = res.headers.get('set-cookie')
|
||||||
|
return {
|
||||||
|
result : json.result,
|
||||||
|
newCookie: setCookie ? parseCookie(setCookie) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function delugeLogin(config: Record<string, string | number>): Promise<string> {
|
||||||
|
const url = delugeUrl(config)
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body : JSON.stringify({ method: 'auth.login', params: [String(config.password ?? '')], id: 1 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Deluge HTTP ${res.status} : ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.error) throw new Error(`Deluge login : ${json.error.message ?? JSON.stringify(json.error)}`)
|
||||||
|
if (!json.result) throw new Error('Deluge : authentification échouée (mot de passe incorrect ?)')
|
||||||
|
|
||||||
|
const setCookie = res.headers.get('set-cookie')
|
||||||
|
if (!setCookie) throw new Error('Deluge : pas de cookie de session reçu après login')
|
||||||
|
|
||||||
|
return parseCookie(setCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Call avec gestion de session ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function delugeCall(
|
||||||
|
config : Record<string, string | number>,
|
||||||
|
method : string,
|
||||||
|
params : unknown[] = [],
|
||||||
|
): Promise<any> {
|
||||||
|
const url = delugeUrl(config)
|
||||||
|
const key = sessionKey(config)
|
||||||
|
|
||||||
|
// Récupère ou rafraîchit la session
|
||||||
|
let entry = _sessions.get(key)
|
||||||
|
if (!entry || Date.now() >= entry.expires) {
|
||||||
|
const cookie = await delugeLogin(config)
|
||||||
|
entry = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||||
|
_sessions.set(key, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result, newCookie } = await delugeRPC(url, method, params, entry.cookie)
|
||||||
|
// Mise à jour du cookie si Deluge en renvoie un nouveau
|
||||||
|
if (newCookie) {
|
||||||
|
entry.cookie = newCookie
|
||||||
|
entry.expires = Date.now() + 30 * 60_000
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : ''
|
||||||
|
// Session expirée → relogin unique
|
||||||
|
if (msg.includes('Not authorized') || msg.includes('auth') || msg.includes('401')) {
|
||||||
|
_sessions.delete(key)
|
||||||
|
const cookie = await delugeLogin(config)
|
||||||
|
const fresh = { cookie, expires: Date.now() + 30 * 60_000 }
|
||||||
|
_sessions.set(key, fresh)
|
||||||
|
const { result } = await delugeRPC(url, method, params, cookie)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mapping état ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mapState(state: string): TorrentInfo['state'] {
|
||||||
|
switch (state) {
|
||||||
|
case 'Downloading':
|
||||||
|
case 'Queued' :
|
||||||
|
case 'Allocating' :
|
||||||
|
case 'Moving' : return 'downloading'
|
||||||
|
case 'Seeding' : return 'seeding'
|
||||||
|
case 'Paused' : return 'paused'
|
||||||
|
case 'Checking' : return 'checking'
|
||||||
|
case 'Error' : return 'error'
|
||||||
|
default : return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Aplatissement de l'arborescence de fichiers Deluge ────────────────────────
|
||||||
|
// web.get_torrent_files retourne :
|
||||||
|
// { contents: { "nom": { type: "file"|"dir", index, progress, priority, contents? } } }
|
||||||
|
|
||||||
|
function flattenDelugeFiles(
|
||||||
|
contents: Record<string, any>,
|
||||||
|
prefix = '',
|
||||||
|
): TorrentFileProgress[] {
|
||||||
|
const result: TorrentFileProgress[] = []
|
||||||
|
for (const [name, node] of Object.entries(contents ?? {})) {
|
||||||
|
const fullPath = prefix ? `${prefix}/${name}` : name
|
||||||
|
if (node.type === 'dir') {
|
||||||
|
result.push(...flattenDelugeFiles(node.contents ?? {}, fullPath))
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
index : typeof node.index === 'number' ? node.index : 0,
|
||||||
|
name : fullPath,
|
||||||
|
progress: typeof node.progress === 'number' ? node.progress : 0,
|
||||||
|
priority: typeof node.priority === 'number' ? node.priority : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sélection de fichier (asynchrone, best-effort) ──────────────────────────
|
||||||
|
// Attend que les métadonnées soient disponibles (~60 s max) puis applique
|
||||||
|
// les priorités : fichier voulu = 1 (normal), tous les autres = 0 (skip).
|
||||||
|
|
||||||
|
async function applyFilePriority(
|
||||||
|
config : Record<string, string | number>,
|
||||||
|
hash : string,
|
||||||
|
fileIndex: number,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
try {
|
||||||
|
const status = await delugeCall(config, 'core.get_torrent_status', [hash, ['num_files', 'has_metadata']])
|
||||||
|
const numFiles = typeof status?.num_files === 'number' ? status.num_files : 0
|
||||||
|
if (numFiles === 0 || !status?.has_metadata) continue
|
||||||
|
|
||||||
|
const priorities = Array.from({ length: numFiles }, (_, i) => i === fileIndex ? 1 : 0)
|
||||||
|
await delugeCall(config, 'core.set_torrent_options', [[hash], { file_priorities: priorities }])
|
||||||
|
logger.info('deluge', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||||
|
return
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
logger.warn('deluge', `Timeout : priorité fichier non appliquée pour ${hash.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const delugeDriver: TorrentClientDriver = {
|
||||||
|
definition: {
|
||||||
|
id : 'deluge',
|
||||||
|
label : 'Deluge',
|
||||||
|
fields: [
|
||||||
|
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8112', required: true },
|
||||||
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||||
|
{ key: 'category', label: 'Catégorie (Label plugin)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||||
|
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
|
||||||
|
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
|
||||||
|
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Test ───────────────────────────────────────────────────────────────────
|
||||||
|
async test(config) {
|
||||||
|
try {
|
||||||
|
_sessions.delete(sessionKey(config))
|
||||||
|
const cookie = await delugeLogin(config)
|
||||||
|
_sessions.set(sessionKey(config), { cookie, expires: Date.now() + 30 * 60_000 })
|
||||||
|
logger.info('deluge', `Test de connexion réussi sur ${config.url}`)
|
||||||
|
return { ok: true, message: 'Connexion réussie' }
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
logger.warn('deluge', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||||
|
return { ok: false, message: msg }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Healthcheck ────────────────────────────────────────────────────────────
|
||||||
|
async healthcheck(config) {
|
||||||
|
try {
|
||||||
|
// daemon.get_version() retourne la version du daemon Deluge
|
||||||
|
const version = await delugeCall(config, 'daemon.get_version')
|
||||||
|
return { online: true, version: String(version ?? 'inconnue') }
|
||||||
|
} catch {
|
||||||
|
return { online: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── List ───────────────────────────────────────────────────────────────────
|
||||||
|
async list(config, category?) {
|
||||||
|
const keys = [
|
||||||
|
'name', 'state', 'progress', 'total_size', 'total_done',
|
||||||
|
'total_uploaded', 'ratio', 'download_payload_rate', 'upload_payload_rate',
|
||||||
|
'eta', 'save_path', 'label',
|
||||||
|
]
|
||||||
|
// filter_dict vide → tous les torrents ; le filtrage par label se fait en JS
|
||||||
|
// pour ne pas dépendre du Label plugin côté serveur.
|
||||||
|
const raw: Record<string, any> = await delugeCall(config, 'core.get_torrents_status', [{}, keys]) ?? {}
|
||||||
|
|
||||||
|
return Object.entries(raw)
|
||||||
|
.filter(([_, t]) => !category || (t.label ?? '') === category)
|
||||||
|
.map(([hash, t]): TorrentInfo => ({
|
||||||
|
hash : hash.toLowerCase(),
|
||||||
|
name : String(t.name ?? ''),
|
||||||
|
state : mapState(String(t.state ?? '')),
|
||||||
|
progress : Math.round(t.progress ?? 0), // déjà en %
|
||||||
|
size : typeof t.total_size === 'number' ? t.total_size : 0,
|
||||||
|
downloaded: typeof t.total_done === 'number' ? t.total_done : 0,
|
||||||
|
uploaded : typeof t.total_uploaded === 'number' ? t.total_uploaded : 0,
|
||||||
|
ratio : Math.round((t.ratio ?? 0) * 100) / 100,
|
||||||
|
speed : typeof t.download_payload_rate === 'number' ? t.download_payload_rate : 0,
|
||||||
|
upspeed : typeof t.upload_payload_rate === 'number' ? t.upload_payload_rate : 0,
|
||||||
|
eta : typeof t.eta === 'number' && t.eta >= 0 ? t.eta : -1,
|
||||||
|
save_path : String(t.save_path ?? ''),
|
||||||
|
category : String(t.label ?? ''),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Add ────────────────────────────────────────────────────────────────────
|
||||||
|
async add(config, url, options?: DownloadOptions) {
|
||||||
|
const addOpts: Record<string, unknown> = {}
|
||||||
|
if (config.savePath) addOpts['download_location'] = String(config.savePath)
|
||||||
|
|
||||||
|
let hash: string | null = null
|
||||||
|
|
||||||
|
if (url.startsWith('magnet:')) {
|
||||||
|
hash = await delugeCall(config, 'core.add_torrent_magnet', [url, addOpts])
|
||||||
|
logger.info('deluge', `Magnet ajouté → ${hash?.slice(0, 8) ?? '?'}…`)
|
||||||
|
} else {
|
||||||
|
// Télécharge le .torrent puis l'envoie en base64 via core.add_torrent_file
|
||||||
|
const torrentRes = await fetch(url)
|
||||||
|
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent : ${torrentRes.status}`)
|
||||||
|
const buf = await torrentRes.arrayBuffer()
|
||||||
|
const b64 = Buffer.from(buf).toString('base64')
|
||||||
|
const filename = url.split('/').pop()?.replace(/\?.*$/, '') ?? 'torrent.torrent'
|
||||||
|
hash = await delugeCall(config, 'core.add_torrent_file', [filename, b64, addOpts])
|
||||||
|
logger.info('deluge', `.torrent uploadé → ${hash?.slice(0, 8) ?? '?'}…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label plugin — best-effort (silencieux si plugin absent)
|
||||||
|
if (config.category && hash) {
|
||||||
|
delugeCall(config, 'label.set_torrent', [hash, String(config.category)]).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélection de fichier — asynchrone, best-effort
|
||||||
|
if (options?.file_index != null && hash) {
|
||||||
|
applyFilePriority(config, hash, options.file_index).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Remove ─────────────────────────────────────────────────────────────────
|
||||||
|
async remove(config, hash, deleteFiles = false) {
|
||||||
|
await delugeCall(config, 'core.remove_torrent', [hash, deleteFiles])
|
||||||
|
logger.info('deluge', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Get files ──────────────────────────────────────────────────────────────
|
||||||
|
async getFiles(config, hash) {
|
||||||
|
const data = await delugeCall(config, 'web.get_torrent_files', [hash])
|
||||||
|
if (!data?.contents) return []
|
||||||
|
return flattenDelugeFiles(data.contents)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default delugeDriver
|
||||||
@@ -22,9 +22,12 @@ export interface FieldDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TorrentClientDefinition {
|
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
|
||||||
|
* torrents ajoutés via FanKarr (clients sans catégories natives). */
|
||||||
|
filterByManagedHashes?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedClient {
|
export interface SavedClient {
|
||||||
@@ -94,7 +97,64 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
|||||||
|
|
||||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||||
|
|
||||||
|
// ─── Managed tasks ─────────────────────────────────────────────────────────────
|
||||||
|
// Stocke les infohashes des torrents ajoutés via FanKarr, par UUID client.
|
||||||
|
// Utilisé par dispatchList pour filtrer les résultats des clients sans
|
||||||
|
// catégories natives (ex: Synology Download Station).
|
||||||
|
// Structure : { clientUuid: [infohash, ...] }
|
||||||
|
|
||||||
|
const MANAGED_PATH = path.join(DATA_DIR, 'managed_tasks.json')
|
||||||
|
|
||||||
|
function loadManagedMap(): Record<string, string[]> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(MANAGED_PATH)) return {}
|
||||||
|
return JSON.parse(fs.readFileSync(MANAGED_PATH, 'utf-8'))
|
||||||
|
} catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManagedMap(data: Record<string, string[]>) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(MANAGED_PATH), { recursive: true })
|
||||||
|
fs.writeFileSync(MANAGED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne le Set des infohashes gérés pour un client, ou null si vide. */
|
||||||
|
function getManagedSet(clientUuid: string): Set<string> | null {
|
||||||
|
const list = loadManagedMap()[clientUuid]
|
||||||
|
if (!list || list.length === 0) return null
|
||||||
|
return new Set(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre un infohash comme géré pour un client (idempotent). */
|
||||||
|
export function trackManagedHash(clientUuid: string, infohash: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) data[clientUuid] = []
|
||||||
|
const h = infohash.toLowerCase()
|
||||||
|
if (!data[clientUuid].includes(h)) {
|
||||||
|
data[clientUuid].push(h)
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire un infohash de la liste gérée pour un client. */
|
||||||
|
export function untrackManagedHash(clientUuid: string, infohash: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) return
|
||||||
|
data[clientUuid] = data[clientUuid].filter(h => h !== infohash.toLowerCase())
|
||||||
|
if (data[clientUuid].length === 0) delete data[clientUuid]
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime toutes les tâches gérées d'un client (appelé lors de la suppression du client). */
|
||||||
|
function clearManagedHashes(clientUuid: string) {
|
||||||
|
const data = loadManagedMap()
|
||||||
|
if (!data[clientUuid]) return
|
||||||
|
delete data[clientUuid]
|
||||||
|
saveManagedMap(data)
|
||||||
|
}
|
||||||
|
|
||||||
function loadClients(): SavedClient[] {
|
function loadClients(): SavedClient[] {
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +189,7 @@ export function removeClient(uuid: string): boolean {
|
|||||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||||
if (filtered.length === clients.length) return false
|
if (filtered.length === clients.length) return false
|
||||||
saveClients(filtered)
|
saveClients(filtered)
|
||||||
|
clearManagedHashes(uuid)
|
||||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -243,6 +304,10 @@ export async function dispatchDownload(url: string, options?: DownloadOptions):
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await driver.add(client.config, url, options)
|
await driver.add(client.config, url, options)
|
||||||
|
if (options?.infohash) {
|
||||||
|
trackManagedHash(client.uuid, options.infohash)
|
||||||
|
logger.debug('torrent-clients', `Tâche ${options.infohash.slice(0, 8)}… suivie pour "${client.name}"`)
|
||||||
|
}
|
||||||
logger.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) {
|
||||||
@@ -266,6 +331,7 @@ export async function dispatchRemove(hash: string, deleteFiles = false): Promise
|
|||||||
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)
|
||||||
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 inconnue'
|
||||||
@@ -328,6 +394,14 @@ export async function dispatchList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtre par hashes gérés pour les clients sans catégories natives.
|
||||||
|
// Si le Set de tâches suivies est non-vide et que ce hash n'y figure pas,
|
||||||
|
// on ignore ce torrent (il n'a pas été ajouté via FanKarr).
|
||||||
|
if (driver.definition.filterByManagedHashes) {
|
||||||
|
const managed = getManagedSet(client.uuid)
|
||||||
|
if (managed && !managed.has(hash)) continue
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -2,21 +2,87 @@
|
|||||||
* Synology Download Station Driver
|
* Synology Download Station Driver
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
|
||||||
import { logger } from '../logger.js'
|
import { logger } from '../logger.js'
|
||||||
|
import { DATA_DIR } from '../config.js'
|
||||||
|
|
||||||
|
// ─── Cache URI → infohash ──────────────────────────────────────────────────────
|
||||||
|
// Synology n'expose pas toujours le hash BT via l'API (ancien DSM).
|
||||||
|
// On persiste l'association URI → infohash au moment du add() pour la réutiliser
|
||||||
|
// dans list() via le champ additional.detail.uri de chaque tâche.
|
||||||
|
|
||||||
|
const URI_HASH_PATH = path.join(DATA_DIR, 'synology_uri_hash.json')
|
||||||
|
|
||||||
|
// ─── Cache hash BT → ID Synology (en mémoire) ─────────────────────────────────
|
||||||
|
// Synology utilise des IDs internes (dbid_X), pas des infohash BT.
|
||||||
|
// Quand list() réussit à résoudre le vrai hash BT, on mémorise l'association
|
||||||
|
// infohash → dbid_X pour que remove() / getFiles() puissent retrouver la tâche
|
||||||
|
// même quand detail.hash et detail.uri sont vides.
|
||||||
|
const _hashToSynoId = new Map<string, string>()
|
||||||
|
|
||||||
|
function loadUriHashMap(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(URI_HASH_PATH)) return JSON.parse(fs.readFileSync(URI_HASH_PATH, 'utf-8'))
|
||||||
|
} catch {}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _uriToHash: Map<string, string> = (() => {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
for (const [k, v] of Object.entries(loadUriHashMap())) m.set(k, v)
|
||||||
|
return m
|
||||||
|
})()
|
||||||
|
|
||||||
|
function storeUriHash(uri: string, hash: string) {
|
||||||
|
const key = uri.toLowerCase()
|
||||||
|
if (_uriToHash.get(key) === hash) return // déjà connu, pas besoin d'écrire
|
||||||
|
_uriToHash.set(key, hash)
|
||||||
|
try {
|
||||||
|
const map = loadUriHashMap()
|
||||||
|
map[key] = hash
|
||||||
|
fs.mkdirSync(path.dirname(URI_HASH_PATH), { recursive: true })
|
||||||
|
fs.writeFileSync(URI_HASH_PATH, JSON.stringify(map, null, 2), 'utf-8')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHash(detailHash: string, detailUri: string, taskId: string): string {
|
||||||
|
// 1. Hash direct depuis l'API Synology (DSM récent)
|
||||||
|
if (detailHash.length > 0) return detailHash.toLowerCase()
|
||||||
|
|
||||||
|
// 2. Magnet URI dans detail.uri → extraction directe du hash (hex 40 chars)
|
||||||
|
if (detailUri.startsWith('magnet:')) {
|
||||||
|
const m = detailUri.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||||
|
if (m) return m[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Notre cache URI → infohash (stocké au moment du add())
|
||||||
|
if (detailUri) {
|
||||||
|
const cached = _uriToHash.get(detailUri.toLowerCase())
|
||||||
|
if (cached) return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback : ID Synology natif (déjà au format "dbid_X")
|
||||||
|
logger.debug('synology-ds', `Hash BT non résolu — fallback ID Synology : ${taskId}`)
|
||||||
|
return String(taskId ?? '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State mapping ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Synology Download Station task status codes
|
|
||||||
function mapState(status: string): TorrentInfo['state'] {
|
function mapState(status: string): TorrentInfo['state'] {
|
||||||
if (status === 'downloading') return 'downloading'
|
if (status === 'downloading') return 'downloading'
|
||||||
if (status === 'seeding' || status === 'finished') return 'seeding'
|
if (status === 'seeding' || status === 'finished') return 'seeding'
|
||||||
if (status === 'paused') return 'paused'
|
if (status === 'finishing' || status === 'extracting') return 'seeding'
|
||||||
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
|
if (status === 'paused') return 'paused'
|
||||||
if (status === 'hash_checking') return 'checking'
|
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
|
||||||
if (status === 'error') return 'error'
|
if (status === 'hash_checking') return 'checking'
|
||||||
|
if (status === 'error') return 'error'
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentification DSM — retourne un SID
|
// ─── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
async function dsLogin(config: Record<string, string | number>): Promise<string> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api : 'SYNO.API.Auth',
|
api : 'SYNO.API.Auth',
|
||||||
@@ -27,7 +93,6 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
|
|||||||
session: 'DownloadStation',
|
session: 'DownloadStation',
|
||||||
format : 'sid',
|
format : 'sid',
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
|
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -51,18 +116,21 @@ async function dsRequest(
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Driver ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DS: TorrentClientDriver = {
|
const DS: TorrentClientDriver = {
|
||||||
definition: {
|
definition: {
|
||||||
id : 'synology-ds',
|
id : 'synology-ds',
|
||||||
label : 'Synology Download Station',
|
label : 'Synology Download Station',
|
||||||
fields: [
|
filterByManagedHashes: true,
|
||||||
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
fields : [
|
||||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
|
||||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||||
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||||
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
|
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
|
||||||
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
|
||||||
|
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -92,10 +160,8 @@ const DS: TorrentClientDriver = {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!data.success) return { online: false }
|
if (!data.success) return { online: false }
|
||||||
const version = data.data?.version_string ?? 'inconnue'
|
const version = data.data?.version_string ?? 'inconnue'
|
||||||
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
|
|
||||||
return { online: true, version }
|
return { online: true, version }
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
|
||||||
return { online: false }
|
return { online: false }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -103,71 +169,191 @@ const DS: TorrentClientDriver = {
|
|||||||
async list(config, category) {
|
async list(config, category) {
|
||||||
const sid = await dsLogin(config)
|
const sid = await dsLogin(config)
|
||||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||||
{ additional: 'transfer,detail' }, sid)
|
{ additional: 'transfer,detail,file' }, sid)
|
||||||
|
|
||||||
const tasks: any[] = data?.tasks ?? []
|
const tasks: any[] = data?.tasks ?? []
|
||||||
|
|
||||||
return tasks
|
// Filtrage par dossier cible — Synology n'a pas de système de labels/catégories.
|
||||||
.map(t => {
|
// Si savePath est configuré, on ne garde que les tâches dont la destination correspond.
|
||||||
const transfer = t.additional?.transfer ?? {}
|
const normalizedSavePath = String(config.savePath ?? '').replace(/\/+$/, '').toLowerCase()
|
||||||
const size = t.size ?? 0
|
const filtered = normalizedSavePath
|
||||||
const dl = transfer.size_downloaded ?? 0
|
? tasks.filter(t => {
|
||||||
return {
|
const dest = String(t.additional?.detail?.destination ?? '').replace(/\/+$/, '').toLowerCase()
|
||||||
hash : t.id,
|
return dest === normalizedSavePath || dest.startsWith(normalizedSavePath + '/')
|
||||||
name : t.title,
|
|
||||||
state : mapState(t.status),
|
|
||||||
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
|
||||||
size,
|
|
||||||
downloaded: dl,
|
|
||||||
uploaded : transfer.size_uploaded ?? 0,
|
|
||||||
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
|
||||||
speed : transfer.speed_download ?? 0,
|
|
||||||
upspeed : transfer.speed_upload ?? 0,
|
|
||||||
eta : -1,
|
|
||||||
save_path : t.additional?.detail?.destination ?? '',
|
|
||||||
category : category ?? '',
|
|
||||||
} satisfies TorrentInfo
|
|
||||||
})
|
})
|
||||||
|
: tasks
|
||||||
|
|
||||||
|
return filtered.map(t => {
|
||||||
|
const transfer = t.additional?.transfer ?? {}
|
||||||
|
const detail = t.additional?.detail ?? {}
|
||||||
|
const fileList: any[] = t.additional?.file ?? []
|
||||||
|
const size = t.size ?? 0
|
||||||
|
const dl = transfer.size_downloaded ?? 0
|
||||||
|
|
||||||
|
const hash = resolveHash(
|
||||||
|
String(detail.hash ?? '').trim(),
|
||||||
|
String(detail.uri ?? '').trim(),
|
||||||
|
String(t.id ?? ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mémoriser l'association hash BT → ID Synology (pour remove/getFiles)
|
||||||
|
const synoIdStr = String(t.id ?? '').toLowerCase()
|
||||||
|
if (!hash.startsWith('dbid_') && synoIdStr) {
|
||||||
|
_hashToSynoId.set(hash, synoIdStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fileList.length > 0
|
||||||
|
? fileList.map((f: any, i: number) => {
|
||||||
|
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||||
|
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||||
|
return {
|
||||||
|
index : i,
|
||||||
|
name : String(f.filename ?? ''),
|
||||||
|
progress: fSize > 0 ? fDl / fSize : 0,
|
||||||
|
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash,
|
||||||
|
name : t.title,
|
||||||
|
state : mapState(t.status),
|
||||||
|
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
|
||||||
|
size,
|
||||||
|
downloaded: dl,
|
||||||
|
uploaded : transfer.size_uploaded ?? 0,
|
||||||
|
ratio : size > 0 ? Math.round(((transfer.size_uploaded ?? 0) / size) * 100) / 100 : 0,
|
||||||
|
speed : transfer.speed_download ?? 0,
|
||||||
|
upspeed : transfer.speed_upload ?? 0,
|
||||||
|
eta : -1,
|
||||||
|
save_path : detail.destination ?? '',
|
||||||
|
category : category ?? '',
|
||||||
|
files,
|
||||||
|
} satisfies TorrentInfo
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async add(config, url, options?: DownloadOptions) {
|
async add(config, url, options?: DownloadOptions) {
|
||||||
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
|
if (options?.file_index != null)
|
||||||
|
logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
|
||||||
|
|
||||||
|
// Mémoriser l'association URI → infohash AVANT l'envoi (le hash est connu côté FanKarr)
|
||||||
|
if (options?.infohash) {
|
||||||
|
storeUriHash(url, options.infohash)
|
||||||
|
logger.debug('synology-ds', `URI→hash mémorisé : ${options.infohash.slice(0, 8)}… (${url.slice(-50)})`)
|
||||||
|
}
|
||||||
|
|
||||||
const sid = await dsLogin(config)
|
const sid = await dsLogin(config)
|
||||||
|
|
||||||
const extra: Record<string, string> = { uri: url }
|
// Snapshot des IDs existants avant l'ajout — pour identifier la nouvelle tâche Synology
|
||||||
if (config.savePath) extra.destination = String(config.savePath)
|
const beforeIds = new Set<string>()
|
||||||
|
try {
|
||||||
|
const before = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||||
|
for (const t of before?.tasks ?? []) beforeIds.add(String(t.id))
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const doAdd = async (withDestination: boolean) => {
|
||||||
api : 'SYNO.DownloadStation.Task',
|
const params = new URLSearchParams({
|
||||||
version: '1',
|
api : 'SYNO.DownloadStation.Task',
|
||||||
method : 'create',
|
version: '1',
|
||||||
_sid : sid,
|
method : 'create',
|
||||||
...extra,
|
_sid : sid,
|
||||||
})
|
uri : url,
|
||||||
|
})
|
||||||
|
if (withDestination && config.savePath) params.append('destination', String(config.savePath))
|
||||||
|
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = await doAdd(true)
|
||||||
|
|
||||||
|
// Code 403 = dossier cible introuvable sur le NAS → réessai sans destination
|
||||||
|
if (!data.success && data.error?.code === 403 && config.savePath) {
|
||||||
|
logger.warn('synology-ds', `Dossier cible "${config.savePath}" introuvable (code 403) — ajout sans destination`)
|
||||||
|
data = await doAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
|
||||||
|
|
||||||
|
// Snapshot après ajout — identifier l'ID natif Synology (dbid_X) de la tâche créée
|
||||||
|
// et le stocker dans _hashToSynoId pour remove/getFiles sans attendre le prochain list()
|
||||||
|
if (options?.infohash) {
|
||||||
|
try {
|
||||||
|
const after = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', {}, sid)
|
||||||
|
const newTask = (after?.tasks ?? []).find((t: any) => !beforeIds.has(String(t.id)))
|
||||||
|
if (newTask) {
|
||||||
|
const synoId = String(newTask.id).toLowerCase()
|
||||||
|
_hashToSynoId.set(options.infohash.toLowerCase(), synoId)
|
||||||
|
logger.debug('synology-ds', `ID Synology capturé : ${synoId} → ${options.infohash.slice(0, 8)}…`)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(config, hash, deleteFiles = false) {
|
async getFiles(config, hash) {
|
||||||
const sid = await dsLogin(config)
|
const sid = await dsLogin(config)
|
||||||
// Synology utilise son ID interne — on cherche via la liste
|
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
|
||||||
|
{ additional: 'detail,file' }, sid)
|
||||||
|
const tasks: any[] = data?.tasks ?? []
|
||||||
|
|
||||||
|
const h = hash.toLowerCase()
|
||||||
|
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||||
|
const task = tasks.find((t: any) => {
|
||||||
|
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||||
|
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||||
|
const synoId = String(t.id ?? '').toLowerCase()
|
||||||
|
const cached = _uriToHash.get(detailUri) ?? ''
|
||||||
|
return detailHash === h
|
||||||
|
|| synoId === h
|
||||||
|
|| cached === h
|
||||||
|
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!task) return []
|
||||||
|
const fileList: any[] = task.additional?.file ?? []
|
||||||
|
return fileList.map((f: any, i: number) => {
|
||||||
|
const fSize = typeof f.size === 'number' ? f.size : 0
|
||||||
|
const fDl = typeof f.size_downloaded === 'number' ? f.size_downloaded : 0
|
||||||
|
return {
|
||||||
|
index : i,
|
||||||
|
name : String(f.filename ?? ''),
|
||||||
|
progress: fSize > 0 ? fDl / fSize : 0,
|
||||||
|
priority: String(f.priority ?? 'normal') === 'skip' ? 0 : 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(config, hash, _deleteFiles?) {
|
||||||
|
const sid = await dsLogin(config)
|
||||||
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
|
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
|
||||||
const tasks: any[] = data?.tasks ?? []
|
const tasks: any[] = data?.tasks ?? []
|
||||||
// Matcher par hash si résolu, sinon par id
|
|
||||||
const found = tasks.find(t => t.id?.toLowerCase() === hash.toLowerCase())
|
const h = hash.toLowerCase()
|
||||||
|
const synoIdFromHash = _hashToSynoId.get(h) ?? ''
|
||||||
|
const found = tasks.find((t: any) => {
|
||||||
|
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
|
||||||
|
const detailUri = String(t.additional?.detail?.uri ?? '').toLowerCase()
|
||||||
|
const synoId = String(t.id ?? '').toLowerCase()
|
||||||
|
const cachedHash = _uriToHash.get(detailUri) ?? ''
|
||||||
|
return detailHash === h
|
||||||
|
|| synoId === h
|
||||||
|
|| cachedHash === h
|
||||||
|
|| (synoIdFromHash && synoId === synoIdFromHash)
|
||||||
|
})
|
||||||
|
|
||||||
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api : 'SYNO.DownloadStation.Task',
|
api : 'SYNO.DownloadStation.Task',
|
||||||
version: '1',
|
version : '1',
|
||||||
method : 'delete',
|
method : 'delete',
|
||||||
id : found.id,
|
id : found.id,
|
||||||
force_complete: deleteFiles ? 'true' : 'false',
|
force_complete: 'false',
|
||||||
_sid : sid,
|
_sid : sid,
|
||||||
})
|
})
|
||||||
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
|||||||
@@ -606,33 +606,47 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
|
|||||||
*/
|
*/
|
||||||
function _parseEpChap(plot: string | null | undefined): {
|
function _parseEpChap(plot: string | null | undefined): {
|
||||||
episodes: string | null
|
episodes: string | null
|
||||||
chapters: string | null
|
season: number | null
|
||||||
|
chapters: string |null
|
||||||
cleanPlot: string
|
cleanPlot: string
|
||||||
} {
|
} {
|
||||||
const text = plot ?? ''
|
const text = plot ?? ''
|
||||||
|
|
||||||
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
const epMatch = text.match(
|
||||||
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
|
/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?(?:\s+de\s+la\s+saison\s+(\d+))?/i
|
||||||
|
)
|
||||||
|
|
||||||
|
const seasonMatch = text.match(/saison\s+(\d+)/i)
|
||||||
|
|
||||||
|
const chapMatch = text.match(
|
||||||
|
/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i
|
||||||
|
)
|
||||||
|
|
||||||
const episodes = epMatch
|
const episodes = epMatch
|
||||||
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
|
||||||
: null
|
: null
|
||||||
const chapters = chapMatch
|
|
||||||
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
const season = epMatch?.[3]
|
||||||
: null
|
? Number(epMatch[3])
|
||||||
|
: seasonMatch
|
||||||
|
? Number(seasonMatch[1])
|
||||||
|
: null
|
||||||
|
|
||||||
|
const chapters = chapMatch
|
||||||
|
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
|
||||||
|
: null
|
||||||
|
|
||||||
// Retirer les lignes contenant les références d'épisodes ou chapitres
|
|
||||||
let cleanPlot = text
|
let cleanPlot = text
|
||||||
if (episodes || chapters) {
|
if (episodes || chapters || season) {
|
||||||
cleanPlot = text
|
cleanPlot = text
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
|
.filter(line => !/(?:[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+|saison\s+\d+)/i.test(line))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { episodes, chapters, cleanPlot }
|
return { episodes, season, chapters, cleanPlot }
|
||||||
}
|
}
|
||||||
function parseEpChap(plot: string | null | undefined) {
|
function parseEpChap(plot: string | null | undefined) {
|
||||||
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="modal.config[field.key]"
|
v-model="modal.config[field.key]"
|
||||||
:type="field.type === 'password' ? 'password' : 'text'"
|
:type="field.type === 'password' ? 'password' : 'text'"
|
||||||
placeholder=""
|
:placeholder="field.placeholder ?? ''"
|
||||||
class="settings-input"
|
class="settings-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,11 +254,7 @@ const clientMatrix = [
|
|||||||
{
|
{
|
||||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'alldebrid', label: 'AllDebrid', version: 'API v4',
|
|
||||||
auth: 'Clé API', fileSelect: false, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const modal = ref({
|
const modal = ref({
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.17.0
|
3.20.2
|
||||||
Reference in new issue
Block a user