refactor: use qBittorrent Sync API (/api/v2/sync/maindata) with fallback
All checks were successful
Docs Check / Markdown lint (push) Successful in 51s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m16s
CI / Tests & coverage (push) Successful in 1m37s
CI / Security audit (push) Successful in 1m44s
Docs Check / Mermaid diagram parse check (push) Successful in 1m52s

- QBittorrentClient now uses the incremental Sync API instead of repeatedly
  fetching the full torrent list via /api/v2/torrents/info.
- Per-client state: lastRid, torrentMap, fallbackThisCycle.
- Handles full_update, delta updates, and torrents_removed.
- Falls back to legacy torrents/info at most once per poll cycle.
- getAllTorrents() resets fallback flags before each cycle.
- Added 9 new unit tests covering: first sync, delta merge, full_update,
  torrents_removed, fallback path, direct-legacy-after-fallback, 403 re-auth,
  completed-field computation, and fallback reset.
This commit is contained in:
2026-05-19 09:33:20 +01:00
parent 8c4cc20551
commit 0a54d0d302
3 changed files with 392 additions and 11 deletions

View File

@@ -11,6 +11,10 @@ class QBittorrentClient {
this.username = instance.username;
this.password = instance.password;
this.authCookie = null;
// Sync API incremental state
this.lastRid = 0;
this.torrentMap = new Map();
this.fallbackThisCycle = false;
}
async login() {
@@ -80,19 +84,110 @@ class QBittorrentClient {
}
}
async getTorrents() {
/**
* Fetches incremental torrent data using the qBittorrent Sync API.
*
* The Sync API uses a response ID (rid) to send only changed fields:
* - First call uses rid=0 to get the full torrent list.
* - Subsequent calls send the last received rid; qBittorrent returns
* delta updates (changed fields only), new torrents, and removed hashes.
* - If full_update is true, the server is sending a full refresh and
* we rebuild our local map from scratch.
*
* @returns {Promise<Array>} Array of complete torrent objects.
*/
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`);
// Add instance info to each torrent
return response.data.map(torrent => ({
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;
}
}
/**
* Returns the current list of torrents for this instance.
* Uses the Sync API for incremental updates; falls back to torrents/info
* at most once per polling cycle if the Sync API call fails.
*/
async getTorrents() {
try {
if (this.fallbackThisCycle) {
logToFile(`[qBittorrent:${this.name}] Already fell back this cycle, using legacy`);
const torrents = await this.getTorrentsLegacy();
return torrents.map(torrent => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
}
const torrents = await this.getMainData();
logToFile(`[qBittorrent:${this.name}] Sync: ${torrents.length} torrents (rid=${this.lastRid})`);
return torrents.map(torrent => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
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 => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
} catch (fallbackError) {
logToFile(`[qBittorrent:${this.name}] Fallback also failed: ${fallbackError.message}`);
return [];
}
}
}
}
@@ -117,7 +212,13 @@ async function getAllTorrents() {
if (clients.length === 0) {
return [];
}
// Reset fallback flags at the start of each poll cycle so every cycle
// gets one chance to use the Sync API before falling back.
for (const client of clients) {
client.fallbackThisCycle = false;
}
const results = await Promise.all(
clients.map(client => client.getTorrents().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
@@ -216,5 +317,6 @@ module.exports = {
mapTorrentToDownload,
formatBytes,
formatSpeed,
formatEta
formatEta,
QBittorrentClient
};