- Fix seriesMap key (use Sonarr internal id, not tvdbId) - Fix Sonarr tag resolution (use tag map like Radarr) - Use sourceTitle for history record matching - Fall back to embedded movie/series objects when API timeouts - Add includeMovie/includeSeries params to queue/history API calls - Add coverArt field to all download responses (TMDB poster URLs) - Add cover art display to frontend download cards - Fix user-summary route to use instance config and tag maps
213 lines
5.9 KiB
JavaScript
213 lines
5.9 KiB
JavaScript
const axios = require('axios');
|
|
const { logToFile } = require('./logger');
|
|
const { getQbittorrentInstances } = require('./config');
|
|
|
|
class QBittorrentClient {
|
|
constructor(instance) {
|
|
this.id = instance.id;
|
|
this.name = instance.name;
|
|
this.url = instance.url;
|
|
this.username = instance.username;
|
|
this.password = instance.password;
|
|
this.authCookie = null;
|
|
}
|
|
|
|
async login() {
|
|
try {
|
|
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
|
|
const response = await axios.post(`${this.url}/api/v2/auth/login`,
|
|
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
maxRedirects: 0,
|
|
validateStatus: (status) => status >= 200 && status < 400
|
|
}
|
|
);
|
|
|
|
if (response.headers['set-cookie']) {
|
|
this.authCookie = response.headers['set-cookie'][0];
|
|
logToFile(`[qBittorrent:${this.name}] Login successful`);
|
|
return true;
|
|
}
|
|
|
|
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
|
|
return false;
|
|
} catch (error) {
|
|
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async makeRequest(endpoint, config = {}) {
|
|
const url = `${this.url}${endpoint}`;
|
|
|
|
if (!this.authCookie) {
|
|
const loggedIn = await this.login();
|
|
if (!loggedIn) {
|
|
throw new Error(`Failed to authenticate with ${this.name}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await axios.get(url, {
|
|
...config,
|
|
headers: {
|
|
...config.headers,
|
|
'Cookie': this.authCookie
|
|
}
|
|
});
|
|
return response;
|
|
} catch (error) {
|
|
// If unauthorized, try re-authenticating once
|
|
if (error.response && error.response.status === 403) {
|
|
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
|
|
this.authCookie = null;
|
|
const loggedIn = await this.login();
|
|
if (loggedIn) {
|
|
return axios.get(url, {
|
|
...config,
|
|
headers: {
|
|
...config.headers,
|
|
'Cookie': this.authCookie
|
|
}
|
|
});
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getTorrents() {
|
|
try {
|
|
const response = await this.makeRequest('/api/v2/torrents/info');
|
|
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
|
|
// Add instance info to each torrent
|
|
return response.data.map(torrent => ({
|
|
...torrent,
|
|
instanceId: this.id,
|
|
instanceName: this.name
|
|
}));
|
|
} catch (error) {
|
|
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
function getClients() {
|
|
const instances = getQbittorrentInstances();
|
|
if (instances.length === 0) {
|
|
logToFile('[qBittorrent] No instances configured');
|
|
return [];
|
|
}
|
|
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
|
|
return instances.map(inst => new QBittorrentClient(inst));
|
|
}
|
|
|
|
async function getAllTorrents() {
|
|
const clients = getClients();
|
|
if (clients.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const results = await Promise.all(
|
|
clients.map(client => client.getTorrents().catch(err => {
|
|
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
|
|
return [];
|
|
}))
|
|
);
|
|
|
|
const allTorrents = results.flat();
|
|
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
|
|
return allTorrents;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatSpeed(bytesPerSecond) {
|
|
return formatBytes(bytesPerSecond) + '/s';
|
|
}
|
|
|
|
function formatEta(seconds) {
|
|
if (seconds < 0 || seconds === 8640000) return '∞'; // qBittorrent uses 8640000 for unknown
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
return `${minutes}m`;
|
|
}
|
|
|
|
function mapTorrentToDownload(torrent) {
|
|
const totalSize = torrent.size;
|
|
const downloadedSize = torrent.completed;
|
|
const progress = torrent.progress * 100;
|
|
|
|
// Map qBittorrent states to our status
|
|
const stateMap = {
|
|
'downloading': 'Downloading',
|
|
'stalledDL': 'Downloading',
|
|
'metaDL': 'Downloading',
|
|
'forcedDL': 'Downloading',
|
|
'allocating': 'Downloading',
|
|
'uploading': 'Seeding',
|
|
'stalledUP': 'Seeding',
|
|
'forcedUP': 'Seeding',
|
|
'queuedUP': 'Queued',
|
|
'queuedDL': 'Queued',
|
|
'checkingUP': 'Checking',
|
|
'checkingDL': 'Checking',
|
|
'checkingResumeData': 'Checking',
|
|
'moving': 'Moving',
|
|
'pausedUP': 'Paused',
|
|
'pausedDL': 'Paused',
|
|
'stoppedUP': 'Stopped',
|
|
'stoppedDL': 'Stopped',
|
|
'error': 'Error',
|
|
'missingFiles': 'Error',
|
|
'unknown': 'Unknown'
|
|
};
|
|
|
|
const status = stateMap[torrent.state] || torrent.state;
|
|
|
|
return {
|
|
type: 'torrent',
|
|
title: torrent.name,
|
|
instanceName: torrent.instanceName,
|
|
status: status,
|
|
progress: progress.toFixed(1),
|
|
size: formatBytes(totalSize),
|
|
rawSize: totalSize,
|
|
rawDownloaded: downloadedSize,
|
|
speed: formatSpeed(torrent.dlspeed),
|
|
rawSpeed: torrent.dlspeed,
|
|
eta: formatEta(torrent.eta),
|
|
rawEta: torrent.eta,
|
|
seeds: torrent.num_seeds,
|
|
peers: torrent.num_leechs,
|
|
availability: (torrent.availability * 100).toFixed(1),
|
|
hash: torrent.hash,
|
|
category: torrent.category,
|
|
tags: torrent.tags,
|
|
qbittorrent: true
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
getTorrents: getAllTorrents,
|
|
getClients,
|
|
mapTorrentToDownload,
|
|
formatBytes,
|
|
formatSpeed,
|
|
formatEta
|
|
};
|