517 lines
15 KiB
JavaScript
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; |