Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/lib/DataFetchService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down
191 changes: 157 additions & 34 deletions server/lib/qbittorrent/QBittorrentHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ const logger = require('../logger');
const response = require('../responseFormatter');
const { minutesToMs } = require('../timeRange');
const { verifyPassword } = require('../authUtils');
const { convertToQBittorrentInfo } = 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 {
Expand All @@ -38,6 +40,8 @@ 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.getTorrentFiles = this.getTorrentFiles.bind(this);
this.addTorrent = this.addTorrent.bind(this);
this.deleteTorrent = this.deleteTorrent.bind(this);
this.pauseTorrent = this.pauseTorrent.bind(this);
Expand Down Expand Up @@ -375,6 +379,80 @@ 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.
* @param {boolean} forceRefresh - Invalidate cache and fetch a fresh snapshot
*/
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?.();

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.
* Tries the cached snapshot first, then forces a fresh fetch before 404.
* @returns {Promise<object|null>}
*/
async _findTorrentInfoByHash(hash) {
const lower = String(hash).toLowerCase();

const search = async (forceRefresh) => {
const downloads = await this._getAmuleDownloads(forceRefresh);

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 (await search(false)) ?? (await search(true));
}

/**
* GET /api/v2/torrents/info
*/
Expand All @@ -394,25 +472,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) {
Expand Down Expand Up @@ -441,12 +503,64 @@ 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');
}
}

/**
* 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, category } = req.body;
const urls = req.body.urls;
const savepath = req.body.savepath || req.body.save_path || '';

if (!urls) {
return response.badRequest(res, 'Missing urls parameter');
Expand All @@ -457,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 {
Expand All @@ -489,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?.();
Expand Down
61 changes: 61 additions & 0 deletions server/lib/qbittorrent/addParams.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading