Compare commits

...
27 Commits
Author SHA1 Message Date
Quentin Robert 8991524463 Merge pull request #126 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.3
2026-05-27 23:33:54 +02:00
Quentin Robert 3fe1006a1e chore(main): release 3.18.3 2026-05-27 23:33:06 +02:00
MasutaYunikon 63c8f8a841 Merge remote-tracking branch 'origin/main' 2026-05-27 23:32:31 +02:00
MasutaYunikon 25877685ea fix(synology): double prefix dbid_ et fallback hash 2026-05-27 23:31:23 +02:00
github-actions[bot] 5b111272d4 chore: update version.txt to v3.18.2 2026-05-27 20:16:43 +00:00
Quentin Robert 67365336bb Merge pull request #125 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.2
2026-05-27 22:16:09 +02:00
Quentin Robert 3a991c8fcf chore(main): release 3.18.2 2026-05-27 22:08:17 +02:00
MasutaYunikon d85708588c Merge remote-tracking branch 'origin/main' 2026-05-27 22:07:41 +02:00
MasutaYunikon 63f6c1276b fix(client): synology use default savepath when 403 2026-05-27 22:07:31 +02:00
github-actions[bot] 8b4d5e20a9 chore: update version.txt to v3.18.1 2026-05-27 15:00:49 +00:00
Quentin Robert 9b22b1f377 Merge pull request #124 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.1
2026-05-27 17:00:13 +02:00
Quentin Robert d1357e7a79 chore(main): release 3.18.1 2026-05-27 16:59:45 +02:00
MasutaYunikon e15e7beb1b Merge remote-tracking branch 'origin/main' 2026-05-27 16:59:01 +02:00
MasutaYunikon 8b4b198697 fix(client): suppresion de alldebrid
- Supprime le driver AllDebrid et son enregistrement
- Synology : extrait le vrai hash BT depuis additional.detail.hash
- Synology : ajoute les statuts finishing/extracting → seeding
- Synology : fix remove() pour matcher par hash BT ou ID Synology
- Synology : add() passe en POST form-encoded
2026-05-27 16:57:28 +02:00
github-actions[bot] 13700c554a chore: update version.txt to v3.18.0 2026-05-25 23:21:02 +00:00
Quentin Robert 1e6506aee6 Merge pull request #123 from Masutayunikon/release-please--branches--main
chore(main): release 3.18.0
2026-05-26 01:20:38 +02:00
Quentin Robert 2235541000 chore(main): release 3.18.0 2026-05-26 01:20:01 +02:00
MasutaYunikon 6f5b9e2ee4 Merge remote-tracking branch 'origin/main' 2026-05-26 01:19:32 +02:00
MasutaYunikon 045213d363 fix(client): alldebrid
feat(client): ajout deluge
2026-05-26 01:19:21 +02:00
github-actions[bot] 1f0c9a338a chore: update version.txt to v3.17.1 2026-05-24 13:25:00 +00:00
Quentin Robert 59b40cfd59 Merge pull request #122 from Masutayunikon/release-please--branches--main
chore(main): release 3.17.1
2026-05-24 15:24:40 +02:00
Quentin Robert fc4598a810 chore(main): release 3.17.1 2026-05-24 15:22:23 +02:00
MasutaYunikon 7b1863180f fix(api): la recherche ne retourne plus les series sans torrents
fix(client): alldebrid utilise maintenant les routes 4.1
2026-05-24 15:21:54 +02:00
MasutaYunikon edec048e30 fix(readme): update 2026-05-23 20:04:15 +02:00
MasutaYunikon 4690874be1 Merge remote-tracking branch 'origin/main' 2026-05-23 20:00:11 +02:00
MasutaYunikon bf9b18ec00 fix(readme): update 2026-05-23 20:00:00 +02:00
github-actions[bot] d08d6e6032 chore: update version.txt to v3.17.0 2026-05-23 17:49:19 +00:00
8 changed files with 556 additions and 232 deletions

No files matched your search

+43
View File
@@ -1,5 +1,48 @@
# Changelog
## [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)
+129 -2
View File
@@ -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)
- **"Tout télécharger"** unifié — sélectionne automatiquement les épisodes non couverts par l'intégrale choisie et complète avec les packs individuels
- **Progression par fichier** — barre de téléchargement au niveau de l'épisode, même pour les fichiers dans un pack intégrale
- **RSS Sync** — surveillance automatique des nouvelles sorties toutes les 6 heures pour les séries abonnées, téléchargement sans intervention
### 🗂️ Organisation
- **Import automatique** des fichiers terminés vers votre médiathèque, déclenché dès la fin d'un téléchargement ou toutes les 5 minutes en arrière-plan
- **Scan de médiathèque** — détecte les fichiers déjà présents sur le disque et les référence sans les déplacer
- **Mode hardlink** (recommandé), **déplacement** ou **copie** — le hardlink préserve le seeding du client torrent
- **Désimport automatique** optionnel quand un fichier est supprimé du disque
- **Suppression du dossier série** automatique lors d'un désimport complet avec suppression des fichiers
### 📋 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
- **Badges de langue** VOSTFR et MULTI avec drapeaux sur chaque épisode (détectés automatiquement)
- **Support NFO** — nommage compatible avec les plugins Jellyfin/Plex utilisant les fichiers `.nfo`
- **Intégration Plex** — connexion à votre serveur Plex pour déclencher le scan médiathèque après import
### 👥 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
- **Logs centralisés** — filtre par niveau et source, rotation automatique, clear depuis l'UI
- **Authentification** par mot de passe avec session JWT
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
- **Multi-client torrent** — qBittorrent, Transmission, Synology Download Station, µTorrent, rTorrent, Real-Debrid, AllDebrid
- **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
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
- [fankai.fr](https://fankai.fr) — Le projet Fankai
- [Plugin Jellyfin FanKarr Search](https://github.com/Masutayunikon/jellyfin-plugin-fankarr-search) — Recherche et demandes FanKarr dans Jellyfin
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
+2 -2
View File
@@ -13,7 +13,7 @@ import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.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 { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
@@ -47,7 +47,7 @@ registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
registerDriver(allDebridDriver)
registerDriver(delugeDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
+1 -1
View File
@@ -97,6 +97,7 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
}
const results = all
.filter(s => availableSet.has(s.id))
.filter(s => !q || s.title?.toLowerCase().includes(q) || s.original_title?.toLowerCase().includes(q))
.slice(0, 50)
.map(s => ({
@@ -104,7 +105,6 @@ router.get('/v1/series/search', requireApiToken, async (req, res) => {
title : s.title,
original_title: s.original_title ?? null,
image : s.poster_image ?? null,
available : availableSet.has(s.id),
year : s.year ?? null,
rating : s.rating?.value ?? null,
description : s.plot ?? null,
-199
View File
@@ -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
+319
View File
@@ -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
+61 -27
View File
@@ -7,12 +7,13 @@ import { logger } from '../logger.js'
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'finishing' || status === 'extracting') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
@@ -110,10 +111,21 @@ const DS: TorrentClientDriver = {
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const detail = t.additional?.detail ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
// Synology expose le hash BT dans additional.detail.hash
// Fallback sur l'ID Synology natif (déjà au format "dbid_X")
const rawHash = String(detail.hash ?? '').trim()
const hash = rawHash.length > 0
? rawHash.toLowerCase()
: String(t.id ?? '').toLowerCase()
if (!rawHash) logger.debug('synology-ds', `Hash BT absent pour "${t.title}" — matching par titre (ID: ${t.id})`)
return {
hash : t.id,
hash,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
@@ -124,7 +136,7 @@ const DS: TorrentClientDriver = {
speed : transfer.speed_download ?? 0,
upspeed : transfer.speed_upload ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
save_path : detail.destination ?? '',
category : category ?? '',
} satisfies TorrentInfo
})
@@ -134,39 +146,61 @@ const DS: TorrentClientDriver = {
if (options?.file_index != null) logger.warn('synology-ds', 'Sélection de fichier non supportée — téléchargement complet')
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
const makeBody = (withDestination: boolean) => {
const body = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
uri : url,
})
if (withDestination && config.savePath) body.append('destination', String(config.savePath))
return body
}
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const doRequest = async (body: URLSearchParams) => {
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi`, {
method : 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body : body.toString(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
let data = await doRequest(makeBody(true))
// Code 403 = destination inexistante 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 doRequest(makeBody(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)}`)
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
async remove(config, hash, deleteFiles = false) {
const sid = await dsLogin(config)
// Synology utilise son ID interne — on cherche via la liste
async remove(config, hash, _deleteFiles?) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1', { additional: 'detail' }, sid)
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 found = tasks.find((t: any) => {
const detailHash = String(t.additional?.detail?.hash ?? '').toLowerCase()
const synoId = String(t.id ?? '').toLowerCase()
return detailHash === h || synoId === h || `dbid_${synoId}` === h
})
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'true' : 'false',
force_complete: 'false',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
@@ -175,4 +209,4 @@ const DS: TorrentClientDriver = {
},
}
export default DS
export default DS
+1 -1
View File
@@ -1 +1 @@
3.17.0
3.18.3