mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
657 lines
22 KiB
TypeScript
657 lines
22 KiB
TypeScript
/**
|
|
* Component: Transmission Integration Service
|
|
* Documentation: documentation/phase3/download-clients.md
|
|
*/
|
|
|
|
import axios, { AxiosInstance } from 'axios';
|
|
import { RMAB_USER_AGENT } from '../utils/user-agent';
|
|
import https from 'https';
|
|
import path from 'path';
|
|
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
|
|
import * as parseTorrentModule from 'parse-torrent';
|
|
import { RMABLogger } from '../utils/logger';
|
|
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
|
import {
|
|
IDownloadClient,
|
|
DownloadClientType,
|
|
ProtocolType,
|
|
DownloadInfo,
|
|
DownloadStatus,
|
|
AddDownloadOptions,
|
|
ConnectionTestResult,
|
|
} from '../interfaces/download-client.interface';
|
|
|
|
// Handle both ESM and CommonJS imports
|
|
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
|
|
|
const logger = RMABLogger.create('Transmission');
|
|
|
|
/** Transmission RPC numeric status codes */
|
|
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
|
|
/** Transmission torrent fields we request */
|
|
interface TransmissionTorrent {
|
|
hashString: string;
|
|
name: string;
|
|
totalSize: number;
|
|
downloadedEver: number;
|
|
percentDone: number;
|
|
status: TransmissionStatus;
|
|
rateDownload: number;
|
|
eta: number;
|
|
labels: string[];
|
|
downloadDir: string;
|
|
doneDate: number;
|
|
errorString: string;
|
|
error: number;
|
|
secondsSeeding: number;
|
|
uploadRatio: number;
|
|
uploadedEver: number;
|
|
}
|
|
|
|
/** Fields we request from the Transmission RPC API */
|
|
const TORRENT_FIELDS = [
|
|
'hashString',
|
|
'name',
|
|
'totalSize',
|
|
'downloadedEver',
|
|
'percentDone',
|
|
'status',
|
|
'rateDownload',
|
|
'eta',
|
|
'labels',
|
|
'downloadDir',
|
|
'doneDate',
|
|
'errorString',
|
|
'error',
|
|
'secondsSeeding',
|
|
'uploadRatio',
|
|
'uploadedEver',
|
|
];
|
|
|
|
export class TransmissionService implements IDownloadClient {
|
|
readonly clientType: DownloadClientType = 'transmission';
|
|
readonly protocol: ProtocolType = 'torrent';
|
|
|
|
private client: AxiosInstance;
|
|
private baseUrl: string;
|
|
private username: string;
|
|
private password: string;
|
|
private defaultSavePath: string;
|
|
private defaultCategory: string;
|
|
private disableSSLVerify: boolean;
|
|
private httpsAgent?: https.Agent;
|
|
private pathMappingConfig: PathMappingConfig;
|
|
private sessionId: string = '';
|
|
|
|
constructor(
|
|
baseUrl: string,
|
|
username: string,
|
|
password: string,
|
|
defaultSavePath: string = '/downloads',
|
|
defaultCategory: string = 'readmeabook',
|
|
disableSSLVerify: boolean = false,
|
|
pathMappingConfig?: PathMappingConfig
|
|
) {
|
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
this.username = username;
|
|
this.password = password;
|
|
this.defaultSavePath = defaultSavePath;
|
|
this.defaultCategory = defaultCategory;
|
|
this.disableSSLVerify = disableSSLVerify;
|
|
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
|
|
|
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
|
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
logger.info('[Transmission] SSL certificate verification disabled');
|
|
}
|
|
|
|
this.client = axios.create({
|
|
baseURL: this.baseUrl,
|
|
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
|
httpsAgent: this.httpsAgent,
|
|
headers: { 'User-Agent': RMAB_USER_AGENT },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Execute an RPC request to Transmission.
|
|
* Handles CSRF token (409 → capture X-Transmission-Session-Id → retry).
|
|
*/
|
|
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
|
|
const body = { method, arguments: args };
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (this.sessionId) {
|
|
headers['X-Transmission-Session-Id'] = this.sessionId;
|
|
}
|
|
|
|
// Add Basic Auth if credentials provided
|
|
const auth = this.username
|
|
? { username: this.username, password: this.password }
|
|
: undefined;
|
|
|
|
try {
|
|
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
|
return response.data;
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
|
// Capture CSRF token and retry
|
|
const newSessionId = error.response.headers['x-transmission-session-id'];
|
|
if (newSessionId) {
|
|
this.sessionId = newSessionId;
|
|
headers['X-Transmission-Session-Id'] = this.sessionId;
|
|
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
|
|
return response.data;
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// IDownloadClient Implementation
|
|
// =========================================================================
|
|
|
|
async testConnection(): Promise<ConnectionTestResult> {
|
|
try {
|
|
const data = await this.rpc('session-get', { fields: ['version'] });
|
|
|
|
if (data.result !== 'success') {
|
|
return { success: false, message: `Transmission RPC error: ${data.result}` };
|
|
}
|
|
|
|
const version = data.arguments?.version;
|
|
return {
|
|
success: true,
|
|
version,
|
|
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Connection failed';
|
|
|
|
if (axios.isAxiosError(error)) {
|
|
const code = error.code;
|
|
const status = error.response?.status;
|
|
|
|
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
|
|
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
|
|
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
|
|
}
|
|
if (code === 'ECONNREFUSED') {
|
|
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
|
|
}
|
|
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
|
|
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
|
|
}
|
|
if (code === 'ENOTFOUND') {
|
|
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
|
|
}
|
|
if (status === 401) {
|
|
return { success: false, message: 'Authentication failed. Check your username and password.' };
|
|
}
|
|
}
|
|
|
|
logger.error('Connection test failed', { error: message });
|
|
return { success: false, message };
|
|
}
|
|
}
|
|
|
|
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
|
|
if (!url || typeof url !== 'string' || url.trim() === '') {
|
|
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
|
}
|
|
|
|
const category = options?.category || this.defaultCategory;
|
|
|
|
if (url.startsWith('magnet:')) {
|
|
return this.addMagnetLink(url, category, options);
|
|
} else {
|
|
return this.addTorrentFile(url, category, options);
|
|
}
|
|
}
|
|
|
|
private async addMagnetLink(
|
|
magnetUrl: string,
|
|
category: string,
|
|
options?: AddDownloadOptions
|
|
): Promise<string> {
|
|
const infoHash = this.extractHashFromMagnet(magnetUrl);
|
|
if (!infoHash) {
|
|
throw new Error('Invalid magnet link - could not extract info_hash');
|
|
}
|
|
|
|
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
|
|
|
|
// Check for duplicates
|
|
try {
|
|
await this.getTorrentByHash(infoHash);
|
|
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
|
return infoHash;
|
|
} catch {
|
|
// Torrent doesn't exist, continue
|
|
}
|
|
|
|
const localSavePath = this.defaultSavePath;
|
|
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
|
|
|
const args: Record<string, any> = {
|
|
filename: magnetUrl,
|
|
'download-dir': remoteSavePath,
|
|
paused: options?.paused || false,
|
|
labels: [category],
|
|
};
|
|
|
|
logger.info('[Transmission] Adding magnet link...');
|
|
const data = await this.rpc('torrent-add', args);
|
|
|
|
if (data.result !== 'success') {
|
|
throw new Error(`Transmission rejected magnet link: ${data.result}`);
|
|
}
|
|
|
|
// torrent-add returns torrent-added or torrent-duplicate
|
|
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
|
if (!added) {
|
|
throw new Error('Transmission did not return torrent info after adding');
|
|
}
|
|
|
|
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
|
await this.disableSeedLimits(added.hashString || infoHash);
|
|
|
|
logger.info(`Successfully added magnet link: ${infoHash}`);
|
|
return infoHash;
|
|
}
|
|
|
|
private async addTorrentFile(
|
|
torrentUrl: string,
|
|
category: string,
|
|
options?: AddDownloadOptions
|
|
): Promise<string> {
|
|
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
|
|
|
|
let torrentResponse;
|
|
try {
|
|
torrentResponse = await axios.get(torrentUrl, {
|
|
responseType: 'arraybuffer',
|
|
maxRedirects: 0,
|
|
validateStatus: (status) => status >= 200 && status < 300,
|
|
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
|
headers: { 'User-Agent': RMAB_USER_AGENT },
|
|
});
|
|
|
|
// Check if response body is a magnet link
|
|
if (torrentResponse.data.length > 0) {
|
|
const responseText = torrentResponse.data.toString();
|
|
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
|
|
if (magnetMatch) {
|
|
logger.info('Response body is a magnet link');
|
|
return this.addMagnetLink(magnetMatch[0], category, options);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!axios.isAxiosError(error) || !error.response) {
|
|
throw error;
|
|
}
|
|
|
|
const status = error.response.status;
|
|
|
|
if (status >= 300 && status < 400) {
|
|
const location = error.response.headers['location'];
|
|
if (location && location.startsWith('magnet:')) {
|
|
return this.addMagnetLink(location, category, options);
|
|
}
|
|
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
|
|
try {
|
|
torrentResponse = await axios.get(location, {
|
|
responseType: 'arraybuffer',
|
|
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
|
maxRedirects: 5,
|
|
headers: { 'User-Agent': RMAB_USER_AGENT },
|
|
});
|
|
} catch {
|
|
throw new Error('Failed to download torrent file after redirect');
|
|
}
|
|
} else {
|
|
throw new Error(`Invalid redirect location: ${location}`);
|
|
}
|
|
} else {
|
|
throw new Error(`Failed to download torrent: HTTP ${status}`);
|
|
}
|
|
}
|
|
|
|
const torrentBuffer = Buffer.from(torrentResponse.data);
|
|
|
|
let parsedTorrentData: any;
|
|
try {
|
|
parsedTorrentData = await parseTorrent(torrentBuffer);
|
|
} catch {
|
|
throw new Error('Invalid .torrent file - failed to parse');
|
|
}
|
|
|
|
const infoHash = parsedTorrentData.infoHash;
|
|
if (!infoHash) {
|
|
throw new Error('Failed to extract info_hash from .torrent file');
|
|
}
|
|
|
|
logger.info(`Extracted info_hash: ${infoHash}`);
|
|
|
|
// Check for duplicates
|
|
try {
|
|
await this.getTorrentByHash(infoHash);
|
|
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
|
|
return infoHash;
|
|
} catch {
|
|
// Torrent doesn't exist, continue
|
|
}
|
|
|
|
const localSavePath = this.defaultSavePath;
|
|
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
|
|
|
|
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
|
|
const metainfo = torrentBuffer.toString('base64');
|
|
|
|
const args: Record<string, any> = {
|
|
metainfo,
|
|
'download-dir': remoteSavePath,
|
|
paused: options?.paused || false,
|
|
labels: [category],
|
|
};
|
|
|
|
logger.info('[Transmission] Adding .torrent file...');
|
|
const data = await this.rpc('torrent-add', args);
|
|
|
|
if (data.result !== 'success') {
|
|
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
|
|
}
|
|
|
|
// torrent-add returns torrent-added or torrent-duplicate
|
|
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
|
|
|
|
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
|
|
await this.disableSeedLimits(added?.hashString || infoHash);
|
|
|
|
logger.info(`Successfully added torrent: ${infoHash}`);
|
|
return infoHash;
|
|
}
|
|
|
|
async getDownload(id: string): Promise<DownloadInfo | null> {
|
|
const maxRetries = 3;
|
|
const initialDelayMs = 500;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const torrent = await this.getTorrentByHash(id);
|
|
return this.mapToDownloadInfo(torrent);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : '';
|
|
if (!message.includes('not found')) {
|
|
throw error;
|
|
}
|
|
if (attempt === maxRetries) {
|
|
return null;
|
|
}
|
|
const delayMs = initialDelayMs * Math.pow(2, attempt);
|
|
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async pauseDownload(id: string): Promise<void> {
|
|
try {
|
|
const torrent = await this.getTorrentByHash(id);
|
|
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
|
|
logger.info(`Paused torrent: ${id}`);
|
|
} catch (error) {
|
|
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
|
|
throw new Error('Failed to pause torrent');
|
|
}
|
|
}
|
|
|
|
async resumeDownload(id: string): Promise<void> {
|
|
try {
|
|
const torrent = await this.getTorrentByHash(id);
|
|
await this.rpc('torrent-start', { ids: [torrent.hashString] });
|
|
logger.info(`Resumed torrent: ${id}`);
|
|
} catch (error) {
|
|
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
|
|
throw new Error('Failed to resume torrent');
|
|
}
|
|
}
|
|
|
|
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
|
|
try {
|
|
const torrent = await this.getTorrentByHash(id);
|
|
await this.rpc('torrent-remove', {
|
|
ids: [torrent.hashString],
|
|
'delete-local-data': deleteFiles,
|
|
});
|
|
logger.info(`Deleted torrent: ${id}`);
|
|
} catch (error) {
|
|
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
|
|
throw new Error('Failed to delete torrent');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Post-download cleanup.
|
|
* No-op for Transmission — torrents continue seeding until the
|
|
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
|
|
*/
|
|
async postProcess(_id: string): Promise<void> {
|
|
// No-op: torrents are managed by the seeding cleanup scheduler
|
|
}
|
|
|
|
/**
|
|
* Get available categories/labels.
|
|
* Transmission uses free-form labels — no predefined list to fetch.
|
|
*/
|
|
async getCategories(): Promise<string[]> {
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Set the label for a torrent.
|
|
* Uses the torrent-set RPC method to replace the labels array.
|
|
*/
|
|
async setCategory(id: string, category: string): Promise<void> {
|
|
try {
|
|
const torrent = await this.getTorrentByHash(id);
|
|
await this.rpc('torrent-set', { ids: [torrent.hashString], labels: [category] });
|
|
logger.info(`Set label for torrent ${id}: ${category}`);
|
|
} catch (error) {
|
|
logger.error('Failed to set label', { error: error instanceof Error ? error.message : String(error) });
|
|
throw new Error('Failed to set torrent label');
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Internal Helpers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Disable Transmission's global seed ratio and idle time limits for a torrent.
|
|
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
|
|
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
|
|
*/
|
|
private async disableSeedLimits(hashOrId: string): Promise<void> {
|
|
try {
|
|
await this.rpc('torrent-set', {
|
|
ids: [hashOrId],
|
|
seedRatioMode: 2,
|
|
seedIdleMode: 2,
|
|
});
|
|
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
|
|
} catch (error) {
|
|
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
|
|
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a torrent by its info hash.
|
|
*/
|
|
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
|
|
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
|
|
|
|
if (data.result !== 'success') {
|
|
throw new Error(`Transmission RPC error: ${data.result}`);
|
|
}
|
|
|
|
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
|
|
if (torrents.length === 0) {
|
|
throw new Error(`Torrent ${hash} not found`);
|
|
}
|
|
|
|
return torrents[0];
|
|
}
|
|
|
|
/**
|
|
* Map Transmission torrent to unified DownloadInfo.
|
|
*/
|
|
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
|
|
// Return raw download path (path mapping is applied downstream by the consumer)
|
|
const downloadPath = path.join(torrent.downloadDir, torrent.name);
|
|
|
|
return {
|
|
id: torrent.hashString,
|
|
name: torrent.name,
|
|
size: torrent.totalSize,
|
|
bytesDownloaded: torrent.downloadedEver,
|
|
progress: torrent.percentDone,
|
|
status: this.mapStatus(torrent.status, torrent.error),
|
|
downloadSpeed: torrent.rateDownload,
|
|
eta: torrent.eta < 0 ? 0 : torrent.eta,
|
|
category: torrent.labels?.[0] || '',
|
|
downloadPath,
|
|
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
|
|
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
|
|
seedingTime: torrent.secondsSeeding,
|
|
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map Transmission numeric status to unified DownloadStatus.
|
|
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
|
|
* 4=downloading, 5=seed-pending, 6=seeding
|
|
*/
|
|
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
|
|
if (errorCode > 0) {
|
|
return 'failed';
|
|
}
|
|
|
|
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
|
|
0: 'paused',
|
|
1: 'checking',
|
|
2: 'checking',
|
|
3: 'queued',
|
|
4: 'downloading',
|
|
5: 'seeding',
|
|
6: 'seeding',
|
|
};
|
|
|
|
return statusMap[status] || 'downloading';
|
|
}
|
|
|
|
/**
|
|
* Extract info_hash from magnet link.
|
|
*/
|
|
private extractHashFromMagnet(magnetUrl: string): string | null {
|
|
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
|
|
if (match) {
|
|
return match[1].toLowerCase();
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
|
|
let transmissionServiceInstance: TransmissionService | null = null;
|
|
let configLoaded = false;
|
|
|
|
export async function getTransmissionService(): Promise<TransmissionService> {
|
|
if (transmissionServiceInstance && configLoaded) {
|
|
return transmissionServiceInstance;
|
|
}
|
|
|
|
try {
|
|
const { getConfigService } = await import('../services/config.service');
|
|
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
|
|
const configService = await getConfigService();
|
|
const manager = getDownloadClientManager(configService);
|
|
|
|
logger.info('[Transmission] Loading configuration from download client manager...');
|
|
const clientConfig = await manager.getClientForProtocol('torrent');
|
|
|
|
if (!clientConfig) {
|
|
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
|
|
}
|
|
|
|
if (clientConfig.type !== 'transmission') {
|
|
throw new Error(`Expected Transmission client but found ${clientConfig.type}`);
|
|
}
|
|
|
|
const baseDir = await configService.get('download_dir') || '/downloads';
|
|
const downloadDir = clientConfig.customPath
|
|
? require('path').join(baseDir, clientConfig.customPath)
|
|
: baseDir;
|
|
|
|
const pathMappingConfig: PathMappingConfig = {
|
|
enabled: clientConfig.remotePathMappingEnabled || false,
|
|
remotePath: clientConfig.remotePath || '',
|
|
localPath: clientConfig.localPath || '',
|
|
};
|
|
|
|
logger.info('[Transmission] Config loaded:', {
|
|
name: clientConfig.name,
|
|
hasUrl: !!clientConfig.url,
|
|
hasUsername: !!clientConfig.username,
|
|
hasPassword: !!clientConfig.password,
|
|
disableSSLVerify: clientConfig.disableSSLVerify,
|
|
downloadDir,
|
|
pathMappingEnabled: pathMappingConfig.enabled,
|
|
});
|
|
|
|
if (!clientConfig.url) {
|
|
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
|
|
}
|
|
|
|
transmissionServiceInstance = new TransmissionService(
|
|
clientConfig.url,
|
|
clientConfig.username || '',
|
|
clientConfig.password || '',
|
|
downloadDir,
|
|
clientConfig.category || 'readmeabook',
|
|
clientConfig.disableSSLVerify,
|
|
pathMappingConfig
|
|
);
|
|
|
|
const connectionResult = await transmissionServiceInstance.testConnection();
|
|
if (!connectionResult.success) {
|
|
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
|
|
}
|
|
|
|
logger.info('[Transmission] Connection test successful');
|
|
configLoaded = true;
|
|
return transmissionServiceInstance;
|
|
} catch (error) {
|
|
logger.error('[Transmission] Failed to initialize service', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
transmissionServiceInstance = null;
|
|
configLoaded = false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function invalidateTransmissionService(): void {
|
|
transmissionServiceInstance = null;
|
|
configLoaded = false;
|
|
logger.info('[Transmission] Service singleton invalidated');
|
|
}
|