Compare commits

...
28 Commits
Author SHA1 Message Date
Quentin Robert 5c7b3dc5cd Merge pull request #127 from Masutayunikon/release-please--branches--main
chore(main): release 3.19.0
2026-05-28 15:21:51 +02:00
Quentin Robert f909f554ff chore(main): release 3.19.0 2026-05-28 14:51:23 +02:00
MasutaYunikon fc6d88be5e Merge remote-tracking branch 'origin/main' 2026-05-28 14:50:44 +02:00
MasutaYunikon ed556acd1d fix(torrent): correction suppression torrent après déplacement
- synology-ds: ajout cache _hashToSynoId (hash BT → dbid_X) peuplé dans
  list(), utilisé dans remove() et getFiles() pour retrouver la tâche
  même quand detail.hash et detail.uri sont vides dans la réponse DSM
- index: contrôle du retour de dispatchRemove et log d'erreur explicite
  si la suppression échoue, au lieu de toujours logger "supprimé"
2026-05-28 14:49:30 +02:00
MasutaYunikon c6c52a1ae7 feat(synology): files par fichier + getFiles() comme qbittorrent 2026-05-28 14:34:31 +02:00
MasutaYunikon 98ed3c7199 fix(organize): markOrganized manquant en mode copy pour les packs 2026-05-28 14:33:10 +02:00
MasutaYunikon ee1c7d579c fix(synology): resolution hash via cache URI + magnet + detail.hash 2026-05-28 00:58:53 +02:00
github-actions[bot] ba06dce34d chore: update version.txt to v3.18.3 2026-05-27 21:34:20 +00:00
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
7 changed files with 608 additions and 268 deletions

No files matched your search

+47
View File
@@ -1,5 +1,52 @@
# Changelog
## [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)
+6 -6
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
@@ -210,11 +210,11 @@ server.listen(PORT, async () => {
}
const { organizeMode, deleteTorrentOnMove } = readSettings()
if (organizeMode === 'move' && deleteTorrentOnMove && result.done > 0) {
try {
await dispatchRemove(result.hash, false)
const removeResult = await dispatchRemove(result.hash, false)
if (removeResult.ok) {
logger.info('api', `Torrent "${result.name}" supprimé après move`)
} catch (err) {
logger.warn('api', `Impossible de supprimer le torrent "${result.name}" : ${err instanceof Error ? err.message : err}`)
} else {
logger.warn('api', `Torrent "${result.name}" — suppression échouée après move : ${removeResult.error ?? 'aucun client n\'a pu supprimer'}`)
}
}
}
+6 -1
View File
@@ -458,7 +458,12 @@ async function organizeTorrent(
try {
fs.mkdirSync(destDir, { recursive: true })
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') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
-192
View File
@@ -1,192 +0,0 @@
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
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, base = AD_BASE): string {
const sep = path.includes('?') ? '&' : '?'
return `${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,
base = AD_BASE,
): Promise<any> {
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}`)
}
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:')
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}` : ''
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),
progress,
size,
downloaded,
uploaded : typeof m.uploaded === 'number' ? m.uploaded : 0,
ratio : 0,
speed : typeof m.downloadSpeed === 'number' ? m.downloadSpeed : 0,
upspeed : typeof m.uploadSpeed === 'number' ? m.uploadSpeed : 0,
eta,
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 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)
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]
if (!Array.isArray(magnetEntry?.files)) return []
return flattenFiles(magnetEntry.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
+229 -68
View File
@@ -2,21 +2,87 @@
* Synology Download Station Driver
*/
import fs from 'fs'
import path from 'path'
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'] {
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'
}
// Authentification DSM — retourne un SID
// ─── Auth ──────────────────────────────────────────────────────────────────────
async function dsLogin(config: Record<string, string | number>): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
@@ -27,7 +93,6 @@ async function dsLogin(config: Record<string, string | number>): Promise<string>
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
@@ -51,18 +116,20 @@ async function dsRequest(
return data.data
}
// ─── Driver ────────────────────────────────────────────────────────────────────
const DS: TorrentClientDriver = {
definition: {
id : 'synology-ds',
label : 'Synology Download Station',
fields: [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
@@ -92,10 +159,8 @@ const DS: TorrentClientDriver = {
const data = await res.json()
if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
} catch {
return { online: false }
}
},
@@ -103,71 +168,167 @@ const DS: TorrentClientDriver = {
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid)
{ additional: 'transfer,detail,file' }, sid)
const tasks: any[] = data?.tasks ?? []
return tasks
.map(t => {
const transfer = t.additional?.transfer ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
return {
hash : t.id,
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
})
return tasks.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) {
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 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 getFiles(config, hash) {
const sid = await dsLogin(config)
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 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`)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'delete',
id : found.id,
force_complete: deleteFiles ? 'true' : 'false',
_sid : sid,
api : 'SYNO.DownloadStation.Task',
version : '1',
method : 'delete',
id : found.id,
force_complete: 'false',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -175,4 +336,4 @@ const DS: TorrentClientDriver = {
},
}
export default DS
export default DS
+1 -1
View File
@@ -1 +1 @@
3.17.1
3.19.0