transmission-rss-manager/modules/transmission-client.js
MasterDraco 6dc2df3cee Fix code consistency and reliability issues
This commit addresses multiple code consistency and reliability issues across the codebase:

1. Version consistency - use package.json version (2.0.9) throughout
2. Improved module loading with better error handling and consistent symlinks
3. Enhanced data directory handling with better error checking
4. Fixed redundant code in main-installer.sh
5. Improved error handling in transmission-client.js
6. Added extensive module symlink creation
7. Better file path handling and permission checks
8. Enhanced API response handling

💡 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-07 11:38:14 +00:00

540 lines
16 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 - but don't throw if it fails initially
// This allows the object to be created even if the connection fails
try {
this.initializeConnection();
} catch (error) {
console.error("Failed to initialize Transmission connection:", error.message);
// Don't throw - allow methods to handle connection retry logic
}
}
/**
* Initialize the connection to Transmission
*/
initializeConnection() {
const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig;
try {
// Only default to localhost if host is empty/null/undefined
const connectionHost = (host === undefined || host === null || host === '') ? 'localhost' : host;
this.client = new Transmission({
host: connectionHost,
port: port || 9091,
username: username || '',
password: password || '',
path: rpcPath || '/transmission/rpc',
timeout: 30000 // 30 seconds
});
console.log(`Initialized Transmission client connection to ${connectionHost}:${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 {
// Use the session-stats method for basic connectivity check
const sessionInfo = await this.client.sessionStats();
// Use the session-get method to get version info
// Note: In transmission-promise, this is 'session' not 'sessionGet'
const session = await this.client.session();
return {
connected: true,
version: session.version || "Unknown",
rpcVersion: session['rpc-version'] || "Unknown",
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 {
// Verify client is initialized
if (!this.client) {
await this.initializeConnection();
if (!this.client) {
throw new Error("Failed to initialize Transmission client");
}
}
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 {
// In transmission-promise, the method is sessionUpdate not sessionSet
await this.client.sessionUpdate(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;