Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7b3dc5cd | ||
|
|
f909f554ff | ||
|
|
fc6d88be5e | ||
|
|
ed556acd1d | ||
|
|
c6c52a1ae7 | ||
|
|
98ed3c7199 | ||
|
|
ee1c7d579c | ||
|
|
ba06dce34d | ||
|
|
8991524463 | ||
|
|
3fe1006a1e | ||
|
|
63c8f8a841 | ||
|
|
25877685ea | ||
|
|
5b111272d4 | ||
|
|
67365336bb | ||
|
|
3a991c8fcf | ||
|
|
d85708588c | ||
|
|
63f6c1276b | ||
|
|
8b4d5e20a9 | ||
|
|
9b22b1f377 | ||
|
|
d1357e7a79 | ||
|
|
e15e7beb1b | ||
|
|
8b4b198697 | ||
|
|
13700c554a |
No files matched your search
@@ -1,5 +1,40 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+4
-6
@@ -13,7 +13,6 @@ 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'
|
||||
@@ -48,7 +47,6 @@ registerDriver(synologyDsDriver)
|
||||
registerDriver(utorrentDriver)
|
||||
registerDriver(rtorrentDriver)
|
||||
registerDriver(realDebridDriver)
|
||||
registerDriver(allDebridDriver)
|
||||
registerDriver(delugeDriver)
|
||||
|
||||
const app = express()
|
||||
@@ -212,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'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,197 +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
|
||||
// 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 hasLocalMount ? 'seeding' : 'downloading'
|
||||
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)
|
||||
// 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, Boolean(localBase)),
|
||||
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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
3.18.0
|
||||
3.19.0
|
||||
Reference in new issue
Block a user