// Copyright (c) 2026 Gordon Bolton. MIT License. const axios = require('axios'); const DownloadClient = require('./DownloadClient'); const { logToFile } = require('../utils/logger'); class QBittorrentClient extends DownloadClient { constructor(instance) { super(instance); this.authCookie = null; // Sync API incremental state this.lastRid = 0; this.torrentMap = new Map(); this.fallbackThisCycle = false; } getClientType() { return 'qbittorrent'; } async testConnection() { try { await this.login(); // Try a simple API call to verify connection await this.makeRequest('/api/v2/app/version'); logToFile(`[qBittorrent:${this.name}] Connection test successful`); return true; } catch (error) { logToFile(`[qBittorrent:${this.name}] Connection test failed: ${error.message}`); return false; } } 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; } } /** * Fetches incremental torrent data using the qBittorrent Sync API. */ async getMainData() { const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`); const data = response.data; if (data.full_update) { // Full refresh: rebuild the entire map this.torrentMap.clear(); if (data.torrents) { for (const [hash, props] of Object.entries(data.torrents)) { this.torrentMap.set(hash, { ...props, hash }); } } } else { // Delta update: merge changed fields into existing torrent objects if (data.torrents) { for (const [hash, delta] of Object.entries(data.torrents)) { const existing = this.torrentMap.get(hash) || { hash }; this.torrentMap.set(hash, { ...existing, ...delta }); } } } // Remove torrents that the server reports as deleted if (data.torrents_removed) { for (const hash of data.torrents_removed) { this.torrentMap.delete(hash); } } // Ensure every torrent has a computed 'completed' field for downstream consumers for (const torrent of this.torrentMap.values()) { if (torrent.completed === undefined && torrent.size !== undefined && torrent.progress !== undefined) { torrent.completed = Math.round(torrent.size * torrent.progress); } } this.lastRid = data.rid; return Array.from(this.torrentMap.values()); } /** * Legacy full-list fetch. Used as a fallback when the Sync API fails. */ async getTorrentsLegacy() { try { const response = await this.makeRequest('/api/v2/torrents/info'); logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents (legacy)`); return response.data; } catch (error) { logToFile(`[qBittorrent:${this.name}] Error fetching torrents (legacy): ${error.message}`); throw error; } } async getActiveDownloads() { try { if (this.fallbackThisCycle) { logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`); const torrents = await this.getTorrentsLegacy(); return torrents.map(torrent => this.normalizeDownload(torrent)); } const torrents = await this.getMainData(); logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`); return torrents.map(torrent => this.normalizeDownload(torrent)); } catch (error) { logToFile(`[qBittorrent:${this.name}] Sync failed, falling back to legacy: ${error.message}`); this.fallbackThisCycle = true; try { const torrents = await this.getTorrentsLegacy(); return torrents.map(torrent => this.normalizeDownload(torrent)); } catch (fallbackError) { logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`); return []; } } } async getClientStatus() { try { const response = await this.makeRequest('/api/v2/sync/maindata'); const data = response.data; return { serverState: data.server_state || {}, rid: data.rid, fullUpdate: data.full_update }; } catch (error) { logToFile(`[qBittorrent:${this.name}] Error getting client status: ${error.message}`); return null; } } normalizeDownload(torrent) { const totalSize = torrent.size; const downloadedSize = torrent.completed || Math.round(torrent.size * torrent.progress); const progress = torrent.progress * 100; // Map qBittorrent states to our normalized 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 { id: torrent.hash, title: torrent.name, type: 'torrent', client: 'qbittorrent', instanceId: this.id, instanceName: this.name, status: status, progress: Math.round(progress), size: totalSize, downloaded: downloadedSize, speed: torrent.dlspeed, eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta, category: torrent.category || undefined, tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [], savePath: torrent.content_path || torrent.save_path || undefined, addedOn: torrent.added_on ? new Date(torrent.added_on * 1000).toISOString() : undefined, raw: torrent }; } // Reset fallback flag (called by registry at start of each poll cycle) resetFallbackFlag() { this.fallbackThisCycle = false; } } module.exports = QBittorrentClient;