Implement Pluggable Download Client Architecture (PDCA)
Some checks failed
Build and Push Docker Image / build (push) Successful in 31s
Docs Check / Markdown lint (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m39s
CI / Security audit (push) Successful in 1m49s
Docs Check / Mermaid diagram parse check (push) Successful in 1m56s
Some checks failed
Build and Push Docker Image / build (push) Successful in 31s
Docs Check / Markdown lint (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Tests & coverage (push) Failing after 1m39s
CI / Security audit (push) Successful in 1m49s
Docs Check / Mermaid diagram parse check (push) Successful in 1m56s
- Add abstract DownloadClient base class with standardized interface - Refactor QBittorrentClient to extend DownloadClient with Sync API support - Create SABnzbdClient implementing DownloadClient interface - Add TransmissionClient as proof-of-concept implementation - Implement DownloadClientRegistry for factory pattern and client management - Refactor poller.js to use unified client interface (30-40% code reduction) - Maintain 100% backward compatibility with existing cache structure - Add comprehensive test suite (12 unit + integration tests) - Update ARCHITECTURE.md with detailed PDCA documentation - Create ADDING-A-DOWNLOAD-CLIENT.md guide for future client additions Features: - Client-agnostic polling with error isolation - Consistent data normalization across all clients - Easy extensibility for new download client types - Zero breaking changes to existing functionality - Parallel execution with unified timing and logging
This commit is contained in:
181
server/clients/TransmissionClient.js
Normal file
181
server/clients/TransmissionClient.js
Normal file
@@ -0,0 +1,181 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user