203 lines
9.3 KiB
TypeScript
203 lines
9.3 KiB
TypeScript
|
|
import type { TorrentClientDriver, TorrentInfo, DownloadOptions, ClientConfig } from './index.js'
|
|
import { clientFetch } from './index.js'
|
|
import { logger } from '../logger.js'
|
|
|
|
function mapState(status: number): TorrentInfo['state'] {
|
|
// 0 arrêté, 1-2 vérification (attente, en cours), 3-4 téléchargement (attente, en cours), 5-6 partage (attente, en cours)
|
|
if (status === 4 || status === 3) return 'downloading'
|
|
if (status === 6 || status === 5) return 'seeding'
|
|
if (status === 0) return 'paused'
|
|
if (status === 1 || status === 2) return 'checking'
|
|
return 'unknown'
|
|
}
|
|
|
|
async function trRequest(
|
|
config: ClientConfig,
|
|
method: string,
|
|
args: Record<string, unknown> = {},
|
|
sessionId = ''
|
|
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type' : 'application/json',
|
|
'X-Transmission-Session-Id': sessionId,
|
|
}
|
|
|
|
if (config.username && config.password) {
|
|
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
|
}
|
|
|
|
const res = await clientFetch(config, `${config.url}/transmission/rpc`, {
|
|
method : 'POST',
|
|
headers,
|
|
body : JSON.stringify({ method, arguments: args }),
|
|
})
|
|
|
|
// Première requête : 409 avec l'identifiant de session
|
|
if (res.status === 409) {
|
|
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
|
|
if (!newSessionId) throw new Error('Identifiant de session absent de la réponse de Transmission (HTTP 409)')
|
|
return trRequest(config, method, args, newSessionId)
|
|
}
|
|
|
|
if (!res.ok) throw new Error(`Transmission a répondu HTTP ${res.status}`)
|
|
|
|
const data = await res.json()
|
|
if (data.result !== 'success') throw new Error(`Erreur Transmission : ${data.result}`)
|
|
|
|
return { ...data, sessionId }
|
|
}
|
|
|
|
async function trGetIdByHash(config: ClientConfig, hash: string): Promise<number | null> {
|
|
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
|
const torrents = data.arguments?.torrents ?? []
|
|
return torrents.find((t: any) => t.hashString?.toLowerCase() === hash.toLowerCase())?.id ?? null
|
|
}
|
|
|
|
async function trApplyFilePriority(
|
|
config : ClientConfig,
|
|
hash : string,
|
|
fileIndex: number,
|
|
): Promise<void> {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
await new Promise(r => setTimeout(r, 500))
|
|
try {
|
|
const id = await trGetIdByHash(config, hash)
|
|
if (id == null) continue
|
|
|
|
const data = await trRequest(config, 'torrent-get', { ids: [id], fields: ['files'] })
|
|
const files = data.arguments?.torrents?.[0]?.files
|
|
if (!Array.isArray(files) || files.length === 0) continue
|
|
|
|
const unwanted = files.map((_: any, i: number) => i).filter((i: number) => i !== fileIndex)
|
|
await trRequest(config, 'torrent-set', {
|
|
ids : [id],
|
|
'files-wanted' : [fileIndex],
|
|
'files-unwanted' : unwanted,
|
|
})
|
|
logger.info('transmission', `Fichier n° ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
|
return
|
|
} catch {}
|
|
}
|
|
throw new Error(`Délai dépassé : métadonnées du torrent ${hash.slice(0, 8)}… toujours indisponibles`)
|
|
}
|
|
|
|
const TR: TorrentClientDriver = {
|
|
definition: {
|
|
id : 'transmission',
|
|
label : 'Transmission',
|
|
fields: [
|
|
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
|
{ key: 'username', label: 'Nom d\'utilisateur', type: 'text', placeholder: 'transmission', required: false },
|
|
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
|
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
|
{ key: 'savePath', label: 'Dossier de téléchargement', type: 'text', placeholder: '/downloads/fankai', required: false },
|
|
{ key: 'remotePath', label: 'Dossier vu par le client', type: 'text', placeholder: '/downloads', required: false },
|
|
{ key: 'localPath', label: 'Dossier vu par FanKarr', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
|
|
{ key: 'ignoreCertificateErrors', label: 'Ignorer les erreurs de certificat SSL', type: 'boolean', required: false },
|
|
],
|
|
},
|
|
|
|
async test(config) {
|
|
try {
|
|
await trRequest(config, 'session-get')
|
|
logger.info('transmission', `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 inattendue, consultez les journaux'
|
|
logger.warn('transmission', `Échec du test de connexion sur ${config.url} : ${msg}`)
|
|
return { ok: false, message: msg }
|
|
}
|
|
},
|
|
|
|
async healthcheck(config) {
|
|
try {
|
|
const data = await trRequest(config, 'session-get')
|
|
const version = data.arguments?.version ?? 'inconnue'
|
|
logger.debug('transmission', `Client en ligne (version ${version})`)
|
|
return { online: true, version }
|
|
} catch (err) {
|
|
logger.debug('transmission', `Client injoignable : ${err instanceof Error ? err.message : err}`)
|
|
return { online: false }
|
|
}
|
|
},
|
|
|
|
async list(config, category) {
|
|
const fields = [
|
|
'hashString', 'name', 'status', 'percentDone', 'totalSize',
|
|
'downloadedEver', 'uploadedEver', 'uploadRatio',
|
|
'rateDownload', 'rateUpload', 'eta', 'downloadDir', 'labels',
|
|
'files', // bytesCompleted et length par fichier (fileStats n'a pas length)
|
|
]
|
|
const data = await trRequest(config, 'torrent-get', { fields })
|
|
const torrents: any[] = data.arguments?.torrents ?? []
|
|
|
|
return torrents
|
|
.filter(t => {
|
|
if (!category) return true
|
|
return t.labels?.includes(category)
|
|
})
|
|
.map(t => ({
|
|
hash : t.hashString,
|
|
name : t.name,
|
|
state : mapState(t.status),
|
|
progress : Math.round(t.percentDone * 100),
|
|
size : t.totalSize,
|
|
downloaded: t.downloadedEver,
|
|
uploaded : t.uploadedEver ?? 0,
|
|
ratio : Math.round((t.uploadRatio ?? 0) * 100) / 100,
|
|
speed : t.rateDownload,
|
|
upspeed : t.rateUpload ?? 0,
|
|
eta : t.eta ?? -1,
|
|
save_path : t.downloadDir,
|
|
category : t.labels?.[0] ?? '',
|
|
files : Array.isArray(t.files) && t.files.length > 0
|
|
? t.files.map((f: any, i: number) => ({
|
|
index : i,
|
|
name : String(f.name ?? ''),
|
|
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
|
|
}))
|
|
: undefined,
|
|
} satisfies TorrentInfo))
|
|
},
|
|
|
|
async add(config, url, options?: DownloadOptions) {
|
|
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
|
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
|
|
const hash = hashMatch?.[1]?.toLowerCase() ?? null
|
|
|
|
const args: Record<string, unknown> = { filename: url }
|
|
if (config.savePath) args['download-dir'] = String(config.savePath)
|
|
if (config.category) args['labels'] = [String(config.category)]
|
|
|
|
const res = await trRequest(config, 'torrent-add', args)
|
|
|
|
if (options?.file_index != null && hash) {
|
|
const isDuplicate = !!res.arguments?.['torrent-duplicate']
|
|
if (isDuplicate) {
|
|
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… déjà présent, sélection du fichier n° ${options.file_index}`)
|
|
} else {
|
|
logger.info('transmission', `Torrent ajouté, fichier n° ${options.file_index} sélectionné dès réception des métadonnées`)
|
|
}
|
|
trApplyFilePriority(config, hash, options.file_index).catch(err =>
|
|
logger.warn('transmission', `Priorité de fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
|
)
|
|
return
|
|
}
|
|
|
|
logger.info('transmission', `Torrent ajouté (catégorie : ${config.category ?? 'aucune'}${config.savePath ? `, dossier : ${config.savePath}` : ''})`)
|
|
},
|
|
|
|
async remove(config, hash, deleteFiles = false) {
|
|
// Torrents identifiés par ID numérique : recherche dans la liste
|
|
const data = await trRequest(config, 'torrent-get', { fields: ['hashString', 'id'] })
|
|
const torrents: any[] = data.arguments?.torrents ?? []
|
|
const found = torrents.find(t => t.hashString?.toLowerCase() === hash.toLowerCase())
|
|
if (!found) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable`)
|
|
await trRequest(config, 'torrent-remove', { ids: [found.id], 'delete-local-data': deleteFiles })
|
|
logger.info('transmission', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
|
|
},
|
|
}
|
|
|
|
export default TR
|