/** * Transmission Client Module * Enhanced integration with Transmission BitTorrent client */ const Transmission = require('transmission-promise'); const fs = require('fs').promises; const path = require('path'); const util = require('util'); const exec = util.promisify(require('child_process').exec); class TransmissionClient { constructor(config) { if (!config) { throw new Error('Configuration is required for Transmission client'); } this.config = config; this.client = null; this.dirMappings = null; this.lastSessionId = null; this.connectRetries = 0; this.maxRetries = 5; this.retryDelay = 5000; // 5 seconds // Initialize directory mappings if remote if (config.remoteConfig && config.remoteConfig.isRemote && config.remoteConfig.directoryMapping) { this.dirMappings = config.remoteConfig.directoryMapping; } // Initialize the connection this.initializeConnection(); } /** * Initialize the connection to Transmission */ initializeConnection() { const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig; try { this.client = new Transmission({ host: host || 'localhost', port: port || 9091, username: username || '', password: password || '', path: rpcPath || '/transmission/rpc', timeout: 30000 // 30 seconds }); console.log(`Initialized Transmission client connection to ${host}:${port}${rpcPath}`); } catch (error) { console.error('Failed to initialize Transmission client:', error); throw error; } } /** * Get client status and session information * @returns {Promise} Status information */ async getStatus() { try { const sessionInfo = await this.client.sessionStats(); const version = await this.client.sessionGet(); return { connected: true, version: version.version, rpcVersion: version['rpc-version'], downloadSpeed: sessionInfo.downloadSpeed, uploadSpeed: sessionInfo.uploadSpeed, torrentCount: sessionInfo.torrentCount, activeTorrentCount: sessionInfo.activeTorrentCount }; } catch (error) { console.error('Error getting Transmission status:', error); if (error.message.includes('Connection refused') && this.connectRetries < this.maxRetries) { this.connectRetries++; console.log(`Retrying connection (${this.connectRetries}/${this.maxRetries})...`); return new Promise((resolve) => { setTimeout(async () => { this.initializeConnection(); try { const status = await this.getStatus(); this.connectRetries = 0; // Reset retries on success resolve(status); } catch (retryError) { resolve({ connected: false, error: retryError.message }); } }, this.retryDelay); }); } return { connected: false, error: error.message }; } } /** * Add a torrent from a URL or magnet link * @param {string} url - Torrent URL or magnet link * @param {Object} options - Additional options * @returns {Promise} Result with torrent ID */ async addTorrent(url, options = {}) { try { const downloadDir = options.downloadDir || null; const result = await this.client.addUrl(url, { "download-dir": downloadDir, paused: options.paused || false }); console.log(`Added torrent from ${url}, ID: ${result.id}`); return { success: true, id: result.id, name: result.name, hashString: result.hashString }; } catch (error) { console.error(`Error adding torrent from ${url}:`, error); return { success: false, error: error.message }; } } /** * Get all torrents with detailed information * @param {Array} ids - Optional array of torrent IDs to filter * @returns {Promise} Array of torrent objects */ async getTorrents(ids = null) { try { const torrents = await this.client.get(ids); // Map remote paths to local paths if needed if (this.dirMappings && Object.keys(this.dirMappings).length > 0) { torrents.torrents = torrents.torrents.map(torrent => { torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir); return torrent; }); } return { success: true, torrents: torrents.torrents }; } catch (error) { console.error('Error getting torrents:', error); return { success: false, error: error.message, torrents: [] }; } } /** * Stop torrents by IDs * @param {Array|number} ids - Torrent ID(s) to stop * @returns {Promise} Result */ async stopTorrents(ids) { try { await this.client.stop(ids); return { success: true, message: 'Torrents stopped successfully' }; } catch (error) { console.error(`Error stopping torrents ${ids}:`, error); return { success: false, error: error.message }; } } /** * Start torrents by IDs * @param {Array|number} ids - Torrent ID(s) to start * @returns {Promise} Result */ async startTorrents(ids) { try { await this.client.start(ids); return { success: true, message: 'Torrents started successfully' }; } catch (error) { console.error(`Error starting torrents ${ids}:`, error); return { success: false, error: error.message }; } } /** * Remove torrents by IDs * @param {Array|number} ids - Torrent ID(s) to remove * @param {boolean} deleteLocalData - Whether to delete local data * @returns {Promise} Result */ async removeTorrents(ids, deleteLocalData = false) { try { await this.client.remove(ids, deleteLocalData); return { success: true, message: `Torrents removed successfully${deleteLocalData ? ' with data' : ''}` }; } catch (error) { console.error(`Error removing torrents ${ids}:`, error); return { success: false, error: error.message }; } } /** * Get detailed information for a specific torrent * @param {number} id - Torrent ID * @returns {Promise} Torrent details */ async getTorrentDetails(id) { try { const fields = [ 'id', 'name', 'status', 'hashString', 'downloadDir', 'totalSize', 'percentDone', 'addedDate', 'doneDate', 'uploadRatio', 'rateDownload', 'rateUpload', 'downloadedEver', 'uploadedEver', 'seedRatioLimit', 'error', 'errorString', 'files', 'fileStats', 'peers', 'peersFrom', 'pieces', 'trackers', 'trackerStats', 'labels' ]; const result = await this.client.get(id, fields); if (!result.torrents || result.torrents.length === 0) { return { success: false, error: 'Torrent not found' }; } let torrent = result.torrents[0]; // Map download directory if needed if (this.dirMappings) { torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir); } // Process files for extra information if available if (torrent.files && torrent.files.length > 0) { torrent.mediaInfo = await this.analyzeMediaFiles(torrent.files, torrent.downloadDir); } return { success: true, torrent }; } catch (error) { console.error(`Error getting torrent details for ID ${id}:`, error); return { success: false, error: error.message }; } } /** * Map a remote path to a local path * @param {string} remotePath - Path on the remote server * @returns {string} Local path */ mapRemotePathToLocal(remotePath) { if (!this.dirMappings || !remotePath) { return remotePath; } for (const [remote, local] of Object.entries(this.dirMappings)) { if (remotePath.startsWith(remote)) { return remotePath.replace(remote, local); } } return remotePath; } /** * Analyze media files in a torrent * @param {Array} files - Torrent files * @param {string} baseDir - Base directory of the torrent * @returns {Promise} Media info */ async analyzeMediaFiles(files, baseDir) { try { const mediaInfo = { type: 'unknown', videoFiles: [], audioFiles: [], imageFiles: [], documentFiles: [], archiveFiles: [], otherFiles: [], totalVideoSize: 0, totalAudioSize: 0, totalImageSize: 0, totalDocumentSize: 0, totalArchiveSize: 0, totalOtherSize: 0 }; // File type patterns const videoPattern = /\.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|ts)$/i; const audioPattern = /\.(mp3|flac|wav|aac|ogg|m4a|wma|opus)$/i; const imagePattern = /\.(jpg|jpeg|png|gif|bmp|tiff|webp|svg)$/i; const documentPattern = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|odt|ods|odp|epub|mobi|azw3)$/i; const archivePattern = /\.(zip|rar|7z|tar|gz|bz2|xz|iso)$/i; const subtitlePattern = /\.(srt|sub|sbv|vtt|ass|ssa)$/i; const samplePattern = /sample|trailer/i; // Count files by category for (const file of files) { const fileName = path.basename(file.name).toLowerCase(); const fileSize = file.length; const fileInfo = { name: file.name, size: fileSize, extension: path.extname(file.name).substr(1).toLowerCase(), isSample: samplePattern.test(fileName) }; if (videoPattern.test(fileName)) { mediaInfo.videoFiles.push(fileInfo); mediaInfo.totalVideoSize += fileSize; } else if (audioPattern.test(fileName)) { mediaInfo.audioFiles.push(fileInfo); mediaInfo.totalAudioSize += fileSize; } else if (imagePattern.test(fileName)) { mediaInfo.imageFiles.push(fileInfo); mediaInfo.totalImageSize += fileSize; } else if (documentPattern.test(fileName)) { mediaInfo.documentFiles.push(fileInfo); mediaInfo.totalDocumentSize += fileSize; } else if (archivePattern.test(fileName)) { mediaInfo.archiveFiles.push(fileInfo); mediaInfo.totalArchiveSize += fileSize; } else if (!subtitlePattern.test(fileName)) { mediaInfo.otherFiles.push(fileInfo); mediaInfo.totalOtherSize += fileSize; } } // Determine content type based on file distribution if (mediaInfo.videoFiles.length > 0 && mediaInfo.totalVideoSize > (mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) { mediaInfo.type = 'video'; // Determine if it's a movie or TV show const tvEpisodePattern = /(s\d{1,2}e\d{1,2}|\d{1,2}x\d{1,2})/i; const movieYearPattern = /\(?(19|20)\d{2}\)?/; let tvShowMatch = false; for (const file of mediaInfo.videoFiles) { if (tvEpisodePattern.test(file.name)) { tvShowMatch = true; break; } } if (tvShowMatch) { mediaInfo.type = 'tvshow'; } else if (movieYearPattern.test(files[0].name)) { mediaInfo.type = 'movie'; } } else if (mediaInfo.audioFiles.length > 0 && mediaInfo.totalAudioSize > (mediaInfo.totalVideoSize + mediaInfo.totalDocumentSize)) { mediaInfo.type = 'audio'; } else if (mediaInfo.documentFiles.length > 0 && mediaInfo.totalDocumentSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize)) { // Check if it's a book or magazine const magazinePattern = /(magazine|issue|volume|vol\.)\s*\d+/i; let isMagazine = false; for (const file of mediaInfo.documentFiles) { if (magazinePattern.test(file.name)) { isMagazine = true; break; } } mediaInfo.type = isMagazine ? 'magazine' : 'book'; } else if (mediaInfo.archiveFiles.length > 0 && mediaInfo.totalArchiveSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) { // If archives dominate, we need to check their content mediaInfo.type = 'archive'; } return mediaInfo; } catch (error) { console.error('Error analyzing media files:', error); return { type: 'unknown', error: error.message }; } } /** * Get session stats from Transmission * @returns {Promise} Stats */ async getSessionStats() { try { const stats = await this.client.sessionStats(); return { success: true, stats }; } catch (error) { console.error('Error getting session stats:', error); return { success: false, error: error.message }; } } /** * Set session parameters * @param {Object} params - Session parameters * @returns {Promise} Result */ async setSessionParams(params) { try { await this.client.sessionSet(params); return { success: true, message: 'Session parameters updated successfully' }; } catch (error) { console.error('Error setting session parameters:', error); return { success: false, error: error.message }; } } /** * Verify if a torrent has met seeding requirements * @param {number} id - Torrent ID * @param {Object} requirements - Seeding requirements * @returns {Promise} Whether requirements are met */ async verifyTorrentSeedingRequirements(id, requirements) { try { const { minRatio = 1.0, minTimeMinutes = 60 } = requirements; const details = await this.getTorrentDetails(id); if (!details.success) { return { success: false, error: details.error }; } const torrent = details.torrent; // Check if download is complete if (torrent.percentDone < 1.0) { return { success: true, requirementsMet: false, reason: 'Download not complete', torrent }; } // Check ratio requirement const ratioMet = torrent.uploadRatio >= minRatio; // Check time requirement (doneDate is unix timestamp in seconds) const seedingTimeMinutes = (Date.now() / 1000 - torrent.doneDate) / 60; const timeMet = seedingTimeMinutes >= minTimeMinutes; return { success: true, requirementsMet: ratioMet && timeMet, ratioMet, timeMet, currentRatio: torrent.uploadRatio, currentSeedingTimeMinutes: seedingTimeMinutes, torrent }; } catch (error) { console.error(`Error checking torrent seeding requirements for ID ${id}:`, error); return { success: false, error: error.message }; } } } module.exports = TransmissionClient;