transmission-rss-manager/modules/transmission-client.js
2025-03-04 22:28:11 +00:00

517 lines
15 KiB
JavaScript

/**
* 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<Object>} 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<Object>} 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>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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;