From 052f5ceec3f5437b3ab4a987f14274d62f897139 Mon Sep 17 00:00:00 2001 From: Mika3578 <58137747+Mika3578@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:23:27 +0200 Subject: [PATCH 1/2] fix(qbittorrent): support multipart add and torrent properties LazyLibrarian and other qBittorrent clients send multipart/form-data on torrents/add and verify adds via torrents/properties. Add busboy parsing for the add endpoint and implement properties from cached torrent info. --- server/lib/qbittorrent/QBittorrentHandler.js | 113 ++++++++++++++---- server/lib/qbittorrent/parseTorrentAddBody.js | 40 +++++++ server/lib/qbittorrent/stateMapping.js | 49 +++++++- server/modules/qbittorrentAPI.js | 4 +- server/package-lock.json | 24 +++- server/package.json | 7 +- 6 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 server/lib/qbittorrent/parseTorrentAddBody.js diff --git a/server/lib/qbittorrent/QBittorrentHandler.js b/server/lib/qbittorrent/QBittorrentHandler.js index c41d5ca..328aea8 100644 --- a/server/lib/qbittorrent/QBittorrentHandler.js +++ b/server/lib/qbittorrent/QBittorrentHandler.js @@ -11,7 +11,7 @@ const logger = require('../logger'); const response = require('../responseFormatter'); const { minutesToMs } = require('../timeRange'); const { verifyPassword } = require('../authUtils'); -const { convertToQBittorrentInfo } = require('./stateMapping'); +const { convertToQBittorrentInfo, convertToQBittorrentProperties } = require('./stateMapping'); const { convertMagnetToEd2k } = require('../linkConverter'); const { itemKey } = require('../itemKey'); const preferences = require('./preferences.json'); @@ -38,6 +38,7 @@ class QBittorrentHandler { this.getWebApiVersion = this.getWebApiVersion.bind(this); this.getPreferences = this.getPreferences.bind(this); this.getTorrentsInfo = this.getTorrentsInfo.bind(this); + this.getTorrentProperties = this.getTorrentProperties.bind(this); this.addTorrent = this.addTorrent.bind(this); this.deleteTorrent = this.deleteTorrent.bind(this); this.pauseTorrent = this.pauseTorrent.bind(this); @@ -375,6 +376,65 @@ class QBittorrentHandler { }; } + /** + * Map a unified DataFetchService item to the download shape used by converters. + */ + _mapUnifiedItemToDownload(item) { + return { + fileName: item.name, + fileHash: item.hash, + fileSize: String(item.size || 0), + fileSizeDownloaded: String(item.sizeDownloaded || 0), + progress: String(item.progress || 0), + sourceCount: item.sources?.connected || 0, + speed: item.downloadSpeed || 0, + priority: item.downloadPriority ?? 0, + category: item.categoryId || null, + status: item.status, + uploadSpeed: item.uploadSpeed || 0, + ratio: item.ratio || 0, + uploadTotal: item.uploadTotal || 0, + directory: item.directory || '' + }; + } + + /** + * Fetch active aMule downloads in the normalized download shape. + */ + async _getAmuleDownloads() { + const dataFetchService = require('../DataFetchService'); + const data = await dataFetchService.getOrFetchBatchData(10000); + const items = data?.items || []; + const targetInstanceId = this.getAmuleInstanceId?.(); + + return items + .filter(item => item.client === 'amule' && (!targetInstanceId || item.instanceId === targetInstanceId)) + .map(item => this._mapUnifiedItemToDownload(item)); + } + + /** + * Resolve a torrent hash (magnet or ED2K) to qBittorrent info format. + * @returns {Promise} + */ + async _findTorrentInfoByHash(hash) { + const lower = String(hash).toLowerCase(); + const ed2kHash = this.hashStore.getEd2kHash(lower); + const downloads = await this._getAmuleDownloads(); + + for (const download of downloads) { + const enriched = await this.enrichDownload(download); + const info = convertToQBittorrentInfo(enriched); + const infoHash = String(info.hash || '').toLowerCase(); + const fileHash = String(download.fileHash || '').toLowerCase(); + + if (infoHash === lower || fileHash === lower || (ed2kHash && fileHash === ed2kHash)) { + return info; + } + } + + return null; + } + /** * GET /api/v2/torrents/info */ @@ -394,25 +454,9 @@ class QBittorrentHandler { // Filter to the target aMule instance const targetInstanceId = this.getAmuleInstanceId?.(); - let downloads = items.filter(item => item.client === 'amule' && (!targetInstanceId || item.instanceId === targetInstanceId)); - - // Map unified items to the format expected by convertToQBittorrentInfo - downloads = downloads.map(item => ({ - fileName: item.name, - fileHash: item.hash, - fileSize: String(item.size || 0), - fileSizeDownloaded: String(item.sizeDownloaded || 0), - progress: String(item.progress || 0), - sourceCount: item.sources?.connected || 0, - speed: item.downloadSpeed || 0, - priority: item.downloadPriority ?? 0, - category: item.categoryId || null, - status: item.status, - uploadSpeed: item.uploadSpeed || 0, - ratio: item.ratio || 0, - uploadTotal: item.uploadTotal || 0, - directory: item.directory || '' - })); + let downloads = items + .filter(item => item.client === 'amule' && (!targetInstanceId || item.instanceId === targetInstanceId)) + .map(item => this._mapUnifiedItemToDownload(item)); // Filter by category if requested if (category) { @@ -441,12 +485,39 @@ class QBittorrentHandler { } } + /** + * GET /api/v2/torrents/properties + * + * LazyLibrarian and other clients call this after add to verify the torrent + * exists. Returns 404 with an empty body when the hash is unknown, matching + * qBittorrent Web API behavior. + */ + async getTorrentProperties(req, res) { + try { + const { hash } = req.query; + if (!hash) { + return response.badRequest(res, 'Missing hash parameter'); + } + + const info = await this._findTorrentInfoByHash(hash); + if (!info) { + return res.status(404).send(''); + } + + res.json(convertToQBittorrentProperties(info)); + } catch (error) { + logger.error('[qBittorrent] Get torrent properties error:', error); + return response.serverError(res, 'Failed to get torrent properties'); + } + } + /** * POST /api/v2/torrents/add */ async addTorrent(req, res) { try { - const { urls, category } = req.body; + const urls = req.body.urls; + const category = req.body.category || req.body.label || ''; if (!urls) { return response.badRequest(res, 'Missing urls parameter'); diff --git a/server/lib/qbittorrent/parseTorrentAddBody.js b/server/lib/qbittorrent/parseTorrentAddBody.js new file mode 100644 index 0000000..9730aca --- /dev/null +++ b/server/lib/qbittorrent/parseTorrentAddBody.js @@ -0,0 +1,40 @@ +/** + * Middleware for POST /api/v2/torrents/add + * + * qBittorrent clients (LazyLibrarian, Sonarr, Radarr) send multipart/form-data + * with a dummy file field even for magnet/url adds. The global urlencoded parser + * skips multipart bodies, so we parse fields here and discard file parts. + */ + +const Busboy = require('busboy'); +const logger = require('../logger'); +const response = require('../responseFormatter'); + +function parseTorrentAddBody(req, res, next) { + const contentType = req.headers['content-type'] || ''; + if (!contentType.includes('multipart/form-data')) { + return next(); + } + + const busboy = Busboy({ headers: req.headers }); + req.body = req.body || {}; + + busboy.on('field', (name, value) => { + req.body[name] = value; + }); + + busboy.on('file', (_name, file) => { + file.resume(); + }); + + busboy.on('error', (err) => { + logger.error('[qBittorrent] Multipart parse error:', err); + response.badRequest(res, 'Invalid multipart body'); + }); + + busboy.on('finish', () => next()); + + req.pipe(busboy); +} + +module.exports = parseTorrentAddBody; diff --git a/server/lib/qbittorrent/stateMapping.js b/server/lib/qbittorrent/stateMapping.js index e586843..ad7db30 100644 --- a/server/lib/qbittorrent/stateMapping.js +++ b/server/lib/qbittorrent/stateMapping.js @@ -154,4 +154,51 @@ function convertToQBittorrentInfo(download) { }; } -module.exports = { convertToQBittorrentInfo, STATE_MAP }; +/** + * Convert qBittorrent torrent info to generic properties format. + * Used by GET /api/v2/torrents/properties for LazyLibrarian and other clients + * that verify adds via properties rather than torrents/info. + * + * @param {object} info - Output of convertToQBittorrentInfo() + * @returns {object} qBittorrent properties response + */ +function convertToQBittorrentProperties(info) { + return { + save_path: info.save_path || '', + creation_date: info.added_on || -1, + piece_size: -1, + comment: info.comment || '', + total_wasted: 0, + total_uploaded: info.uploaded || 0, + total_uploaded_session: info.uploaded_session || 0, + total_downloaded: info.downloaded || 0, + total_downloaded_session: info.downloaded_session || 0, + up_limit: info.up_limit ?? -1, + dl_limit: info.dl_limit ?? -1, + time_elapsed: info.time_active || 0, + seeding_time: info.seeding_time || 0, + nb_connections: -1, + nb_connections_limit: -1, + share_ratio: info.ratio || 0, + addition_date: info.added_on || -1, + completion_date: info.completion_on > 0 ? info.completion_on : -1, + created_by: '', + dl_speed_avg: info.dlspeed || 0, + dl_speed: info.dlspeed || 0, + eta: info.eta ?? 8640000, + last_seen: info.seen_complete > 0 ? info.seen_complete : -1, + peers: info.num_leechs || 0, + peers_total: info.num_incomplete || 0, + pieces_have: -1, + pieces_num: -1, + reannounce: info.reannounce || 0, + seeds: info.num_seeds || 0, + seeds_total: info.num_complete || 0, + total_size: info.total_size || info.size || 0, + up_speed_avg: info.upspeed || 0, + up_speed: info.upspeed || 0, + isPrivate: !!info.private + }; +} + +module.exports = { convertToQBittorrentInfo, convertToQBittorrentProperties, STATE_MAP }; diff --git a/server/modules/qbittorrentAPI.js b/server/modules/qbittorrentAPI.js index 2be9d2f..e672dc3 100644 --- a/server/modules/qbittorrentAPI.js +++ b/server/modules/qbittorrentAPI.js @@ -7,6 +7,7 @@ const crypto = require('crypto'); const express = require('express'); const BaseModule = require('../lib/BaseModule'); const QBittorrentHandler = require('../lib/qbittorrent/QBittorrentHandler'); +const parseTorrentAddBody = require('../lib/qbittorrent/parseTorrentAddBody'); const config = require('./config'); const { parseBasicAuth, verifyPassword } = require('../lib/authUtils'); @@ -218,7 +219,8 @@ class QBittorrentAPI extends BaseModule { // Torrents endpoints router.get('/torrents/info', this.handler.getTorrentsInfo); - router.post('/torrents/add', this.handler.addTorrent); + router.get('/torrents/properties', this.handler.getTorrentProperties); + router.post('/torrents/add', parseTorrentAddBody, this.handler.addTorrent); router.post('/torrents/delete', this.handler.deleteTorrent); router.post('/torrents/pause', this.handler.pauseTorrent); router.post('/torrents/resume', this.handler.resumeTorrent); diff --git a/server/package-lock.json b/server/package-lock.json index 5a9ccd8..3db032e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "amutorrent-web-controller", - "version": "3.7.0", + "version": "3.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "amutorrent-web-controller", - "version": "3.7.0", + "version": "3.8.7", "license": "MIT", "dependencies": { "amule-ec-node": "github:got3nks/amule-ec-node#main", @@ -15,6 +15,7 @@ "better-sqlite3-session-store": "^0.1.0", "bittorrent-peerid": "^1.3.6", "body-parser": "^1.20.2", + "busboy": "^1.6.0", "cookie-parser": "^1.4.6", "express": "^4.18.2", "express-rate-limit": "^7.4.0", @@ -292,6 +293,17 @@ "ieee754": "^1.1.13" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1595,6 +1607,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/server/package.json b/server/package.json index e18f10e..42820b9 100644 --- a/server/package.json +++ b/server/package.json @@ -18,17 +18,18 @@ "dependencies": { "amule-ec-node": "github:got3nks/amule-ec-node#main", "bcrypt": "^6.0.0", - "bittorrent-peerid": "^1.3.6", "better-sqlite3": "^9.2.2", "better-sqlite3-session-store": "^0.1.0", + "bittorrent-peerid": "^1.3.6", "body-parser": "^1.20.2", + "busboy": "^1.6.0", "cookie-parser": "^1.4.6", "express": "^4.18.2", + "express-rate-limit": "^7.4.0", "express-session": "^1.17.3", - "maxmind": "^4.3.29", "helmet": "^8.0.0", "ipaddr.js": "^2.2.0", - "express-rate-limit": "^7.4.0", + "maxmind": "^4.3.29", "ws": "^8.14.2", "xmlbuilder2": "^4.0.3", "xmlrpc": "^1.3.2" From fbb7b3c72877e008dc02c7ad2c128bbba935d27f Mon Sep 17 00:00:00 2001 From: Mika3578 <58137747+Mika3578@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:42:33 +0200 Subject: [PATCH 2/2] fix(qbittorrent): harden LazyLibrarian compat layer - Invalidate DataFetchService cache after add and retry properties lookup with a forced refresh - Add GET /api/v2/torrents/files for post-download processing - Map savepath to aMule categories with explicit warnings when ignored - Harden multipart parser (limits, try/catch, settled guard) - Add node:test coverage for multipart, hash lookup, savepath, and cache refresh --- server/lib/DataFetchService.js | 9 ++ server/lib/qbittorrent/QBittorrentHandler.js | 106 ++++++++++---- server/lib/qbittorrent/addParams.js | 61 ++++++++ server/lib/qbittorrent/parseTorrentAddBody.js | 34 ++++- server/lib/qbittorrent/stateMapping.js | 31 ++++- server/lib/qbittorrent/torrentLookup.js | 22 +++ server/modules/qbittorrentAPI.js | 1 + server/package.json | 3 +- server/test/addParams.test.js | 53 +++++++ server/test/dataFetchCache.test.js | 17 +++ server/test/parseTorrentAddBody.test.js | 130 ++++++++++++++++++ server/test/stateMapping.test.js | 66 +++++++++ server/test/torrentInfoRefresh.test.js | 46 +++++++ server/test/torrentLookup.test.js | 29 ++++ 14 files changed, 576 insertions(+), 32 deletions(-) create mode 100644 server/lib/qbittorrent/addParams.js create mode 100644 server/lib/qbittorrent/torrentLookup.js create mode 100644 server/test/addParams.test.js create mode 100644 server/test/dataFetchCache.test.js create mode 100644 server/test/parseTorrentAddBody.test.js create mode 100644 server/test/stateMapping.test.js create mode 100644 server/test/torrentInfoRefresh.test.js create mode 100644 server/test/torrentLookup.test.js diff --git a/server/lib/DataFetchService.js b/server/lib/DataFetchService.js index 0aca133..336571e 100644 --- a/server/lib/DataFetchService.js +++ b/server/lib/DataFetchService.js @@ -59,6 +59,15 @@ class DataFetchService extends BaseModule { return this.getBatchData(); } + /** + * Drop the cached batch snapshot so the next fetch reflects recent changes + * (e.g. immediately after torrents/add). Does not cancel an in-flight fetch. + */ + invalidateBatchCache() { + this._cachedBatchData = null; + this._cacheTimestamp = 0; + } + // ============================================================================ // ENRICHMENT // ============================================================================ diff --git a/server/lib/qbittorrent/QBittorrentHandler.js b/server/lib/qbittorrent/QBittorrentHandler.js index 328aea8..e24b7c6 100644 --- a/server/lib/qbittorrent/QBittorrentHandler.js +++ b/server/lib/qbittorrent/QBittorrentHandler.js @@ -11,9 +11,11 @@ const logger = require('../logger'); const response = require('../responseFormatter'); const { minutesToMs } = require('../timeRange'); const { verifyPassword } = require('../authUtils'); -const { convertToQBittorrentInfo, convertToQBittorrentProperties } = require('./stateMapping'); +const { convertToQBittorrentInfo, convertToQBittorrentProperties, convertToQBittorrentFiles } = require('./stateMapping'); const { convertMagnetToEd2k } = require('../linkConverter'); const { itemKey } = require('../itemKey'); +const { resolveCategoryForAdd } = require('./addParams'); +const { matchesTorrentHash } = require('./torrentLookup'); const preferences = require('./preferences.json'); class QBittorrentHandler { @@ -39,6 +41,7 @@ class QBittorrentHandler { this.getPreferences = this.getPreferences.bind(this); this.getTorrentsInfo = this.getTorrentsInfo.bind(this); this.getTorrentProperties = this.getTorrentProperties.bind(this); + this.getTorrentFiles = this.getTorrentFiles.bind(this); this.addTorrent = this.addTorrent.bind(this); this.deleteTorrent = this.deleteTorrent.bind(this); this.pauseTorrent = this.pauseTorrent.bind(this); @@ -400,10 +403,22 @@ class QBittorrentHandler { /** * Fetch active aMule downloads in the normalized download shape. + * @param {boolean} forceRefresh - Invalidate cache and fetch a fresh snapshot */ - async _getAmuleDownloads() { + async _getAmuleDownloads(forceRefresh = false) { const dataFetchService = require('../DataFetchService'); + + if (forceRefresh) { + dataFetchService.invalidateBatchCache(); + const data = await dataFetchService.getBatchData(); + return this._downloadsFromBatchData(data); + } + const data = await dataFetchService.getOrFetchBatchData(10000); + return this._downloadsFromBatchData(data); + } + + _downloadsFromBatchData(data) { const items = data?.items || []; const targetInstanceId = this.getAmuleInstanceId?.(); @@ -414,25 +429,28 @@ class QBittorrentHandler { /** * Resolve a torrent hash (magnet or ED2K) to qBittorrent info format. + * Tries the cached snapshot first, then forces a fresh fetch before 404. * @returns {Promise} */ async _findTorrentInfoByHash(hash) { const lower = String(hash).toLowerCase(); - const ed2kHash = this.hashStore.getEd2kHash(lower); - const downloads = await this._getAmuleDownloads(); - for (const download of downloads) { - const enriched = await this.enrichDownload(download); - const info = convertToQBittorrentInfo(enriched); - const infoHash = String(info.hash || '').toLowerCase(); - const fileHash = String(download.fileHash || '').toLowerCase(); + const search = async (forceRefresh) => { + const downloads = await this._getAmuleDownloads(forceRefresh); - if (infoHash === lower || fileHash === lower || (ed2kHash && fileHash === ed2kHash)) { - return info; + for (const download of downloads) { + const enriched = await this.enrichDownload(download); + const info = convertToQBittorrentInfo(enriched); + + if (matchesTorrentHash(lower, info, download.fileHash, h => this.hashStore.getEd2kHash(h))) { + return info; + } } - } - return null; + return null; + }; + + return (await search(false)) ?? (await search(true)); } /** @@ -511,13 +529,38 @@ class QBittorrentHandler { } } + /** + * GET /api/v2/torrents/files + * + * LazyLibrarian calls getFiles() during post-download processing. ED2K + * transfers are single-file; we expose one synthetic file entry. + */ + async getTorrentFiles(req, res) { + try { + const { hash } = req.query; + if (!hash) { + return response.badRequest(res, 'Missing hash parameter'); + } + + const info = await this._findTorrentInfoByHash(hash); + if (!info) { + return res.status(404).send(''); + } + + res.json(convertToQBittorrentFiles(info)); + } catch (error) { + logger.error('[qBittorrent] Get torrent files error:', error); + return response.serverError(res, 'Failed to get torrent files'); + } + } + /** * POST /api/v2/torrents/add */ async addTorrent(req, res) { try { const urls = req.body.urls; - const category = req.body.category || req.body.label || ''; + const savepath = req.body.savepath || req.body.save_path || ''; if (!urls) { return response.badRequest(res, 'Missing urls parameter'); @@ -528,24 +571,28 @@ class QBittorrentHandler { return response.serviceUnavailable(res, 'aMule not connected'); } + await this.waitForCategoryInit(); + + const { categoryId, warnings } = resolveCategoryForAdd( + { + category: req.body.category, + label: req.body.label, + savepath + }, + this.categoriesCache + ); + for (const message of warnings) { + logger.warn(`[qBittorrent] ${message}`); + } + const magnetLinks = urls .split(/[\n\r]+/) .map(s => s.trim()) .filter(Boolean); const results = []; - - // Get category ID - let categoryId = 0; - if (category) { - const categoryObj = await this.getCategoryByName(category); - if (categoryObj) { - categoryId = categoryObj.id; - logger.log(`[qBittorrent] Category "${category}" -> ID: ${categoryId}`); - } else { - logger.log(`[qBittorrent] Category "${category}" not found, using default`); - } - } + const dataFetchService = require('../DataFetchService'); + let cacheInvalidated = false; for (const magnetLink of magnetLinks) { try { @@ -560,10 +607,15 @@ class QBittorrentHandler { if (success) { this.hashStore.setMapping(ed2kHash, magnetHash, { fileName: this.extractFileName(magnetLink), - category: category || '', + category: req.body.category || req.body.label || '', addedAt: Date.now() }); + if (!cacheInvalidated) { + dataFetchService.invalidateBatchCache(); + cacheInvalidated = true; + } + // Record ownership for the authenticated API user if (req.apiUser?.id && this.userManager) { const instanceId = this.getAmuleInstanceId?.(); diff --git a/server/lib/qbittorrent/addParams.js b/server/lib/qbittorrent/addParams.js new file mode 100644 index 0000000..7425517 --- /dev/null +++ b/server/lib/qbittorrent/addParams.js @@ -0,0 +1,61 @@ +/** + * Resolve aMule category for POST /api/v2/torrents/add. + * + * aMule picks the download directory from its category configuration. + * Per-torrent savepath is not supported; we map savepath to a category path + * when no explicit category/label is provided and log when values conflict. + */ + +function normalizePath(path) { + if (!path) return ''; + return String(path) + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/\/+$/, ''); +} + +/** + * @param {object} params + * @param {string} [params.category] + * @param {string} [params.label] + * @param {string} [params.savepath] + * @param {Array<{id:number,title:string,path:string}>} categories + * @returns {{ categoryId: number, warnings: string[] }} + */ +function resolveCategoryForAdd({ category, label, savepath }, categories) { + const warnings = []; + const categoryName = category || label || ''; + const normalizedSavepath = normalizePath(savepath); + + if (categoryName) { + const cat = categories.find(c => c.title === categoryName); + if (!cat) { + warnings.push(`Category "${categoryName}" not found, using default`); + return { categoryId: 0, warnings }; + } + + if (normalizedSavepath && cat.path && normalizePath(cat.path) !== normalizedSavepath) { + warnings.push( + `savepath "${savepath}" ignored; category "${categoryName}" uses "${cat.path}"` + ); + } + + return { categoryId: cat.id, warnings }; + } + + if (normalizedSavepath) { + const catByPath = categories.find(c => normalizePath(c.path) === normalizedSavepath); + if (catByPath) { + return { categoryId: catByPath.id, warnings }; + } + + warnings.push( + `savepath "${savepath}" has no matching aMule category; using default. ` + + 'Configure an aMule category with this path.' + ); + } + + return { categoryId: 0, warnings }; +} + +module.exports = { normalizePath, resolveCategoryForAdd }; diff --git a/server/lib/qbittorrent/parseTorrentAddBody.js b/server/lib/qbittorrent/parseTorrentAddBody.js index 9730aca..1229e59 100644 --- a/server/lib/qbittorrent/parseTorrentAddBody.js +++ b/server/lib/qbittorrent/parseTorrentAddBody.js @@ -10,13 +10,39 @@ const Busboy = require('busboy'); const logger = require('../logger'); const response = require('../responseFormatter'); +const MULTIPART_LIMITS = { + fields: 20, + files: 1, + parts: 21, + fieldSize: 64 * 1024, + fileSize: 1024 * 1024 +}; + function parseTorrentAddBody(req, res, next) { const contentType = req.headers['content-type'] || ''; if (!contentType.includes('multipart/form-data')) { return next(); } - const busboy = Busboy({ headers: req.headers }); + let busboy; + let settled = false; + + const finish = (handler) => { + if (settled) return; + settled = true; + handler(); + }; + + try { + busboy = Busboy({ + headers: req.headers, + limits: MULTIPART_LIMITS + }); + } catch (error) { + logger.error('[qBittorrent] Multipart setup error:', error); + return response.badRequest(res, 'Invalid multipart body'); + } + req.body = req.body || {}; busboy.on('field', (name, value) => { @@ -29,10 +55,12 @@ function parseTorrentAddBody(req, res, next) { busboy.on('error', (err) => { logger.error('[qBittorrent] Multipart parse error:', err); - response.badRequest(res, 'Invalid multipart body'); + finish(() => response.badRequest(res, 'Invalid multipart body')); }); - busboy.on('finish', () => next()); + busboy.on('finish', () => { + finish(() => next()); + }); req.pipe(busboy); } diff --git a/server/lib/qbittorrent/stateMapping.js b/server/lib/qbittorrent/stateMapping.js index ad7db30..0661796 100644 --- a/server/lib/qbittorrent/stateMapping.js +++ b/server/lib/qbittorrent/stateMapping.js @@ -201,4 +201,33 @@ function convertToQBittorrentProperties(info) { }; } -module.exports = { convertToQBittorrentInfo, convertToQBittorrentProperties, STATE_MAP }; +/** + * Convert qBittorrent torrent info to per-file list format. + * ED2K downloads are single-file; index 0 represents the whole transfer. + * + * @param {object} info - Output of convertToQBittorrentInfo() + * @returns {Array} qBittorrent torrents/files response + */ +function convertToQBittorrentFiles(info) { + const progress = info.progress ?? 0; + const fileName = info.name || 'Unknown'; + const baseName = fileName.split(/[/\\]/).pop() || fileName; + + return [{ + index: 0, + name: baseName, + size: info.size || info.total_size || 0, + progress, + priority: info.priority ?? 1, + is_seed: progress >= 1.0, + piece_range: [0, 0], + availability: info.availability ?? 0 + }]; +} + +module.exports = { + convertToQBittorrentInfo, + convertToQBittorrentProperties, + convertToQBittorrentFiles, + STATE_MAP +}; diff --git a/server/lib/qbittorrent/torrentLookup.js b/server/lib/qbittorrent/torrentLookup.js new file mode 100644 index 0000000..9c9f927 --- /dev/null +++ b/server/lib/qbittorrent/torrentLookup.js @@ -0,0 +1,22 @@ +/** + * Hash resolution helpers for the qBittorrent compatibility layer. + */ + +/** + * @param {string} hash - Requested hash (BTIH or ED2K) + * @param {object} info - qBittorrent info object from convertToQBittorrentInfo() + * @param {string} fileHash - Raw ED2K hash from the download item + * @param {function(string): string|null} getEd2kHash + */ +function matchesTorrentHash(hash, info, fileHash, getEd2kHash) { + const lower = String(hash).toLowerCase(); + const infoHash = String(info.hash || '').toLowerCase(); + const ed2k = String(fileHash || '').toLowerCase(); + const mappedEd2k = getEd2kHash(lower); + + return infoHash === lower + || ed2k === lower + || (!!mappedEd2k && ed2k === mappedEd2k.toLowerCase()); +} + +module.exports = { matchesTorrentHash }; diff --git a/server/modules/qbittorrentAPI.js b/server/modules/qbittorrentAPI.js index e672dc3..5922536 100644 --- a/server/modules/qbittorrentAPI.js +++ b/server/modules/qbittorrentAPI.js @@ -220,6 +220,7 @@ class QBittorrentAPI extends BaseModule { // Torrents endpoints router.get('/torrents/info', this.handler.getTorrentsInfo); router.get('/torrents/properties', this.handler.getTorrentProperties); + router.get('/torrents/files', this.handler.getTorrentFiles); router.post('/torrents/add', parseTorrentAddBody, this.handler.addTorrent); router.post('/torrents/delete', this.handler.deleteTorrent); router.post('/torrents/pause', this.handler.pauseTorrent); diff --git a/server/package.json b/server/package.json index 42820b9..53dc4e8 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "nodemon server.js" + "dev": "nodemon server.js", + "test": "node --test test/**/*.test.js" }, "keywords": [ "amule", diff --git a/server/test/addParams.test.js b/server/test/addParams.test.js new file mode 100644 index 0000000..a11d231 --- /dev/null +++ b/server/test/addParams.test.js @@ -0,0 +1,53 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveCategoryForAdd } = require('../lib/qbittorrent/addParams'); + +const categories = [ + { id: 1, title: 'books', path: '/incoming/books' }, + { id: 2, title: 'magazines', path: '/incoming/magazines' } +]; + +describe('resolveCategoryForAdd', () => { + it('maps category name to aMule category id', () => { + const result = resolveCategoryForAdd({ category: 'books' }, categories); + assert.equal(result.categoryId, 1); + assert.deepEqual(result.warnings, []); + }); + + it('falls back to label when category is absent', () => { + const result = resolveCategoryForAdd({ label: 'magazines' }, categories); + assert.equal(result.categoryId, 2); + }); + + it('warns when category name is unknown', () => { + const result = resolveCategoryForAdd({ category: 'missing' }, categories); + assert.equal(result.categoryId, 0); + assert.match(result.warnings[0], /not found/); + }); + + it('maps savepath to category when no category is provided', () => { + const result = resolveCategoryForAdd({ savepath: '/incoming/books' }, categories); + assert.equal(result.categoryId, 1); + assert.deepEqual(result.warnings, []); + }); + + it('normalizes savepath separators before lookup', () => { + const result = resolveCategoryForAdd({ savepath: '\\\\incoming\\\\magazines\\\\' }, categories); + assert.equal(result.categoryId, 2); + }); + + it('warns when savepath has no matching category', () => { + const result = resolveCategoryForAdd({ savepath: '/tmp/nowhere' }, categories); + assert.equal(result.categoryId, 0); + assert.match(result.warnings[0], /no matching aMule category/); + }); + + it('warns when savepath conflicts with named category path', () => { + const result = resolveCategoryForAdd( + { category: 'books', savepath: '/other/path' }, + categories + ); + assert.equal(result.categoryId, 1); + assert.match(result.warnings[0], /savepath .* ignored/); + }); +}); diff --git a/server/test/dataFetchCache.test.js b/server/test/dataFetchCache.test.js new file mode 100644 index 0000000..e7363e7 --- /dev/null +++ b/server/test/dataFetchCache.test.js @@ -0,0 +1,17 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const DataFetchService = require('../lib/DataFetchService'); + +describe('DataFetchService cache invalidation', () => { + it('invalidateBatchCache clears a fresh cached snapshot', () => { + DataFetchService._cachedBatchData = { items: [{ hash: 'abc' }] }; + DataFetchService._cacheTimestamp = Date.now(); + + assert.ok(DataFetchService.getCachedBatchData(10000)); + + DataFetchService.invalidateBatchCache(); + + assert.equal(DataFetchService.getCachedBatchData(10000), null); + assert.equal(DataFetchService._cachedBatchData, null); + }); +}); diff --git a/server/test/parseTorrentAddBody.test.js b/server/test/parseTorrentAddBody.test.js new file mode 100644 index 0000000..b84cd50 --- /dev/null +++ b/server/test/parseTorrentAddBody.test.js @@ -0,0 +1,130 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const express = require('express'); +const bodyParser = require('body-parser'); +const parseTorrentAddBody = require('../lib/qbittorrent/parseTorrentAddBody'); + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, '127.0.0.1', () => { + resolve({ server, port: server.address().port }); + }); + }); +} + +function request(port, { method = 'POST', path = '/add', headers = {}, body = null }) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path, + method, + headers + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString('utf8') + }); + }); + } + ); + + req.on('error', reject); + if (body) req.write(body); + req.end(); + }); +} + +function buildMultipart(boundary, parts) { + let payload = ''; + for (const part of parts) { + payload += `--${boundary}\r\n`; + if (part.filename) { + payload += `Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n`; + payload += `Content-Type: application/octet-stream\r\n\r\n`; + payload += `${part.value}\r\n`; + } else { + payload += `Content-Disposition: form-data; name="${part.name}"\r\n\r\n`; + payload += `${part.value}\r\n`; + } + } + payload += `--${boundary}--\r\n`; + return Buffer.from(payload); +} + +describe('parseTorrentAddBody middleware', () => { + let server; + let port; + let lastBody; + + before(async () => { + const app = express(); + app.use(bodyParser.urlencoded({ extended: true })); + app.post('/add', parseTorrentAddBody, (req, res) => { + lastBody = req.body; + res.status(200).json(req.body); + }); + ({ server, port } = await listen(app)); + }); + + after(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('passes through urlencoded requests unchanged', async () => { + const body = 'urls=magnet%3A%3Fxt%3Durn%3Abtih%3Aabc&category=books'; + const response = await request(port, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(body) + }, + body + }); + + assert.equal(response.status, 200); + assert.equal(lastBody.urls, 'magnet:?xt=urn:btih:abc'); + assert.equal(lastBody.category, 'books'); + }); + + it('parses multipart fields and dummy file', async () => { + const boundary = '----cursor-test-boundary'; + const payload = buildMultipart(boundary, [ + { name: 'urls', value: 'magnet:?xt=urn:btih:abc' }, + { name: 'category', value: 'books' }, + { name: 'paused', value: 'false' }, + { name: '_dummy', filename: '_dummy', value: 'x' } + ]); + + const response = await request(port, { + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': payload.length + }, + body: payload + }); + + assert.equal(response.status, 200); + assert.equal(lastBody.urls, 'magnet:?xt=urn:btih:abc'); + assert.equal(lastBody.category, 'books'); + assert.equal(lastBody.paused, 'false'); + }); + + it('returns 400 for malformed multipart content-type', async () => { + const response = await request(port, { + headers: { + 'Content-Type': 'multipart/form-data', + 'Content-Length': 2 + }, + body: Buffer.from('x') + }); + + assert.equal(response.status, 400); + assert.match(response.body, /Invalid multipart body/); + }); +}); diff --git a/server/test/stateMapping.test.js b/server/test/stateMapping.test.js new file mode 100644 index 0000000..53df0ac --- /dev/null +++ b/server/test/stateMapping.test.js @@ -0,0 +1,66 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + convertToQBittorrentProperties, + convertToQBittorrentFiles +} = require('../lib/qbittorrent/stateMapping'); + +const sampleInfo = { + save_path: '/incoming/books', + added_on: 1700000000, + comment: '', + uploaded: 0, + uploaded_session: 0, + downloaded: 500, + downloaded_session: 500, + up_limit: 0, + dl_limit: 0, + time_active: 42, + seeding_time: 0, + ratio: 0, + completion_on: -1, + dlspeed: 1024, + eta: 3600, + seen_complete: -1, + num_leechs: 0, + num_incomplete: 0, + reannounce: 0, + num_seeds: 2, + num_complete: 2, + total_size: 1000, + size: 1000, + upspeed: 0, + private: false, + name: 'Author - Book.epub', + progress: 0.5, + priority: 1, + availability: 0 +}; + +describe('convertToQBittorrentProperties', () => { + it('maps core fields from torrent info', () => { + const props = convertToQBittorrentProperties(sampleInfo); + assert.equal(props.save_path, '/incoming/books'); + assert.equal(props.addition_date, 1700000000); + assert.equal(props.total_size, 1000); + assert.equal(props.isPrivate, false); + }); +}); + +describe('convertToQBittorrentFiles', () => { + it('returns a single-file entry for active downloads', () => { + const files = convertToQBittorrentFiles(sampleInfo); + assert.equal(files.length, 1); + assert.equal(files[0].index, 0); + assert.equal(files[0].name, 'Author - Book.epub'); + assert.equal(files[0].size, 1000); + assert.equal(files[0].progress, 0.5); + assert.equal(files[0].is_seed, false); + }); + + it('marks completed downloads as seeding file', () => { + const files = convertToQBittorrentFiles({ ...sampleInfo, progress: 1.0 }); + assert.equal(files[0].is_seed, true); + assert.equal(files[0].progress, 1.0); + }); +}); diff --git a/server/test/torrentInfoRefresh.test.js b/server/test/torrentInfoRefresh.test.js new file mode 100644 index 0000000..e0bb8b9 --- /dev/null +++ b/server/test/torrentInfoRefresh.test.js @@ -0,0 +1,46 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const QBittorrentHandler = require('../lib/qbittorrent/QBittorrentHandler'); + +describe('QBittorrentHandler hash lookup refresh', () => { + it('retries with a forced refresh when the cached snapshot misses', async () => { + const handler = new QBittorrentHandler(); + handler.hashStore = { getEd2kHash: () => null }; + + const download = { + fileName: 'Book.epub', + fileHash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + fileSize: '1000', + fileSizeDownloaded: '0', + progress: '0', + sourceCount: 0, + speed: 0, + priority: 1, + category: null, + status: 0, + uploadSpeed: 0, + ratio: 0, + uploadTotal: 0, + directory: '/incoming' + }; + + let calls = 0; + handler._getAmuleDownloads = async (forceRefresh) => { + calls += 1; + return forceRefresh ? [download] : []; + }; + + handler.enrichDownload = async (item) => ({ + ...item, + magnetHash: item.fileHash, + categoryName: '', + categoryPath: '/incoming' + }); + + const info = await handler._findTorrentInfoByHash(download.fileHash); + + assert.ok(info); + assert.equal(info.name, 'Book.epub'); + assert.equal(calls, 2); + }); +}); diff --git a/server/test/torrentLookup.test.js b/server/test/torrentLookup.test.js new file mode 100644 index 0000000..1b69f5c --- /dev/null +++ b/server/test/torrentLookup.test.js @@ -0,0 +1,29 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { matchesTorrentHash } = require('../lib/qbittorrent/torrentLookup'); + +const btih = '0123456789abcdef0123456789abcdef01234567'; +const ed2k = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + +describe('matchesTorrentHash', () => { + it('matches qBittorrent info hash (BTIH)', () => { + const info = { hash: btih }; + assert.equal(matchesTorrentHash(btih, info, ed2k, () => null), true); + }); + + it('matches raw ED2K hash', () => { + const info = { hash: btih }; + assert.equal(matchesTorrentHash(ed2k, info, ed2k, () => null), true); + }); + + it('matches BTIH via hash-store mapping to ED2K', () => { + const info = { hash: btih }; + const getEd2kHash = (hash) => (hash === btih ? ed2k : null); + assert.equal(matchesTorrentHash(btih, info, ed2k, getEd2kHash), true); + }); + + it('returns false for unknown hash', () => { + const info = { hash: btih }; + assert.equal(matchesTorrentHash('deadbeef', info, ed2k, () => null), false); + }); +});