// Copyright (c) 2026 Gordon Bolton. MIT License. const axios = require('axios'); const DownloadClient = require('./DownloadClient'); const { logToFile } = require('../utils/logger'); class TransmissionClient extends DownloadClient { constructor(instance) { super(instance); this.sessionId = null; this.rpcUrl = `${this.url}/transmission/rpc`; } getClientType() { return 'transmission'; } async testConnection() { try { await this.makeRequest('session-get'); logToFile(`[Transmission:${this.name}] Connection test successful`); return true; } catch (error) { logToFile(`[Transmission:${this.name}] Connection test failed: ${error.message}`); return false; } } async makeRequest(method, arguments_ = {}, config = {}) { const payload = { method, arguments: arguments_ }; const headers = { 'Content-Type': 'application/json' }; if (this.sessionId) { headers['X-Transmission-Session-Id'] = this.sessionId; } try { const response = await axios.post(this.rpcUrl, payload, { headers, ...config }); if (response.data.result !== 'success') { throw new Error(`Transmission RPC error: ${response.data.result}`); } return response; } catch (error) { // Handle session ID conflict (409 Conflict) if (error.response && error.response.status === 409) { const sessionId = error.response.headers['x-transmission-session-id']; if (sessionId) { this.sessionId = sessionId; logToFile(`[Transmission:${this.name}] Updated session ID`); return this.makeRequest(method, arguments_, config); } } logToFile(`[Transmission:${this.name}] RPC request failed: ${error.message}`); throw error; } } async getActiveDownloads() { try { // Get all torrents with detailed fields const response = await this.makeRequest('torrent-get', { fields: [ 'id', 'name', 'hashString', 'status', 'totalSize', 'sizeWhenDone', 'leftUntilDone', 'rateDownload', 'rateUpload', 'eta', 'downloadedEver', 'uploadedEver', 'percentDone', 'addedDate', 'doneDate', 'trackerStats', 'labels', 'downloadDir', 'error', 'errorString', 'peersConnected', 'peersGettingFromUs', 'peersSendingToUs', 'queuePosition' ] }); const torrents = response.data.arguments.torrents || []; logToFile(`[Transmission:${this.name}] Retrieved ${torrents.length} torrents`); return torrents.map(torrent => this.normalizeDownload(torrent)); } catch (error) { logToFile(`[Transmission:${this.name}] Error fetching torrents: ${error.message}`); return []; } } async getClientStatus() { try { const response = await this.makeRequest('session-get'); const sessionStats = await this.makeRequest('session-stats'); return { session: response.data.arguments, stats: sessionStats.data.arguments }; } catch (error) { logToFile(`[Transmission:${this.name}] Error getting client status: ${error.message}`); return null; } } normalizeDownload(torrent) { // Map Transmission status codes to normalized status const statusMap = { 0: 'Stopped', // TORRENT_STOPPED 1: 'Queued', // TORRENT_CHECK_WAIT 2: 'Checking', // TORRENT_CHECK 3: 'Queued', // TORRENT_DOWNLOAD_WAIT 4: 'Downloading', // TORRENT_DOWNLOAD 5: 'Queued', // TORRENT_SEED_WAIT 6: 'Seeding', // TORRENT_SEED 7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2) }; const status = statusMap[torrent.status] || 'Unknown'; // Calculate progress and sizes const progress = torrent.percentDone * 100; const size = torrent.totalSize; const downloaded = torrent.sizeWhenDone - torrent.leftUntilDone; // Handle ETA - Transmission uses -1 for unknown, -2 for infinite let eta = null; if (torrent.eta >= 0) { eta = torrent.eta; } // Extract category/labels const labels = torrent.labels || []; const category = labels.length > 0 ? labels[0] : undefined; // Try to extract Sonarr/Radarr info from name const arrInfo = this.extractArrInfo(torrent.name); return { id: torrent.hashString, title: torrent.name, type: 'torrent', client: 'transmission', instanceId: this.id, instanceName: this.name, status: status, progress: Math.round(progress), size: size, downloaded: downloaded, speed: torrent.rateDownload, eta: eta, category: category, tags: labels, savePath: torrent.downloadDir || undefined, addedOn: torrent.addedDate ? new Date(torrent.addedDate * 1000).toISOString() : undefined, arrQueueId: arrInfo.queueId, arrType: arrInfo.type, raw: torrent }; } extractArrInfo(filename) { // Similar to SABnzbdClient, try to extract Sonarr/Radarr info // Look for patterns like "Series Name - S01E02 - Episode Title" const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i); if (seriesMatch) { return { type: 'series' }; } // Look for movie year patterns like "Movie Title (2023)" const movieMatch = filename.match(/\((\d{4})\)/); if (movieMatch && !seriesMatch) { return { type: 'movie' }; } return {}; } } module.exports = TransmissionClient;