Compare commits

..
12 Commits
Author SHA1 Message Date
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
7 changed files with 508 additions and 40 deletions

No files matched your search

+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [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
View File
@@ -14,6 +14,7 @@ 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'
@@ -48,6 +49,7 @@ 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,
+34 -36
View File
@@ -1,12 +1,13 @@
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'
const AD_BASE = 'https://api.alldebrid.com/v4.1'
const AD_BASE_V4 = 'https://api.alldebrid.com/v4'
const AD_AGENT = 'FanKarr'
function adUrl(config: Record<string, string | number>, path: string): string {
function adUrl(config: Record<string, string | number>, path: string, base = AD_BASE): string {
const sep = path.includes('?') ? '&' : '?'
return `${AD_BASE}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
return `${base}${path}${sep}agent=${AD_AGENT}&apikey=${config.apiKey}`
}
async function adFetch(
@@ -14,8 +15,9 @@ async function adFetch(
method: 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams | FormData,
base = AD_BASE,
): Promise<any> {
const res = await fetch(adUrl(config, path), { method, body })
const res = await fetch(adUrl(config, path, base), { method, body })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`AllDebrid HTTP ${res.status} : ${text}`)
@@ -30,9 +32,12 @@ async function adFetch(
// 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'] {
// hasLocalMount : true si un chemin local (FUSE/WebDAV) est configuré.
// Sans montage local, statusCode 4 ("prêt sur les serveurs AD") ne doit pas
// déclencher l'import FanKarr — les fichiers ne sont pas encore sur le disque.
function mapStatus(statusCode: number, hasLocalMount: boolean): TorrentInfo['state'] {
switch (statusCode) {
case 4: return 'seeding'
case 4: return hasLocalMount ? 'seeding' : 'downloading'
case 1:
case 0:
case 2:
@@ -101,21 +106,6 @@ const adDriver: TorrentClientDriver = {
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()
@@ -147,20 +137,26 @@ const adDriver: TorrentClientDriver = {
const progress = size > 0
? Math.round((downloaded / size) * 100)
: (statusCode === 4 ? 100 : 0)
const save_path = localBase ? `${localBase}/${m.filename ?? m.id}` : ''
// save_path = dossier PARENT (comme qBittorrent), sans le nom du torrent.
// Le worker construit lui-même le chemin complet via getFiles().
const save_path = localBase
const eta = (typeof m.completionDate === 'number' && m.completionDate > 0)
? Math.max(0, m.completionDate - Math.floor(Date.now() / 1000))
: -1
return {
hash : String(m.hash ?? m.id ?? '').toLowerCase(),
name : String(m.filename ?? m.id ?? ''),
state : mapStatus(statusCode),
state : mapStatus(statusCode, Boolean(localBase)),
progress,
size,
downloaded,
uploaded : 0,
uploaded : typeof m.uploaded === 'number' ? m.uploaded : 0,
ratio : 0,
speed : typeof m.speed === 'number' ? m.speed : 0,
upspeed : 0,
eta : typeof m.eta === 'number' ? m.eta : -1,
speed : typeof m.downloadSpeed === 'number' ? m.downloadSpeed : 0,
upspeed : typeof m.uploadSpeed === 'number' ? m.uploadSpeed : 0,
eta,
save_path,
category : '',
}
@@ -182,17 +178,19 @@ const adDriver: TorrentClientDriver = {
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 statusCode = typeof target.statusCode === 'number' ? target.statusCode : 0
const size = typeof target.size === 'number' ? target.size : 0
const downloaded = typeof target.downloaded === 'number' ? target.downloaded : 0
const overallProgress = size > 0 ? downloaded / size : (statusCode === 4 ? 1 : 0)
if (!Array.isArray(magnet.files)) return []
const body = new URLSearchParams()
body.append('id[]', String(target.id))
const filesData = await adFetch(config, 'POST', '/magnet/files', body, AD_BASE_V4)
const magnetEntry = (filesData.magnets ?? [])[0]
return flattenFiles(magnet.files).map(f => ({ ...f, progress: overallProgress }))
if (!Array.isArray(magnetEntry?.files)) return []
return flattenFiles(magnetEntry.files).map(f => ({ ...f, progress: overallProgress }))
},
}
+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
+1 -1
View File
@@ -1 +1 @@
3.17.0
3.18.0