From 0bab8064848682e345b911c836567295a152efbd Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Feb 2026 10:22:03 -0500 Subject: [PATCH] Apply reverse path mapping in ensureCategory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure ensureCategory applies reverse path mapping (local → remote) before creating or editing qBittorrent categories. Uses PathMapper.reverseTransform to compute the remote save path and updates logging and error details to reference the transformed path. Adds integration tests covering category creation, updating, and no-op when the remote path already matches. --- src/lib/integrations/qbittorrent.service.ts | 18 ++-- .../integrations/qbittorrent.service.test.ts | 85 +++++++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 308021d..3e67776 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -459,12 +459,16 @@ export class QBittorrentService { /** * Ensure category exists in qBittorrent with correct save path * Checks existing categories first, then creates or updates as needed + * Applies reverse path mapping (local → remote) for remote seedbox scenarios */ private async ensureCategory(category: string): Promise { if (!this.cookie) { await this.login(); } + // Apply reverse path mapping (local → remote) to get the path qBittorrent expects + const remoteSavePath = PathMapper.reverseTransform(this.defaultSavePath, this.pathMappingConfig); + try { // First, get all categories to check if it exists and what save path it has const categoriesResponse = await this.client.get('/torrents/categories', { @@ -476,13 +480,13 @@ export class QBittorrentService { if (!existingCategory) { // Category doesn't exist - create it - logger.info(` Creating category "${category}" with save path: ${this.defaultSavePath}`); + logger.info(` Creating category "${category}" with save path: ${remoteSavePath}`); await this.client.post( '/torrents/createCategory', new URLSearchParams({ category, - savePath: this.defaultSavePath, + savePath: remoteSavePath, }), { headers: { @@ -497,14 +501,14 @@ export class QBittorrentService { // Category exists - check if save path needs updating const currentSavePath = existingCategory.savePath || existingCategory.save_path; - if (currentSavePath !== this.defaultSavePath) { - logger.info(` Updating category "${category}" save path from "${currentSavePath}" to "${this.defaultSavePath}"`); + if (currentSavePath !== remoteSavePath) { + logger.info(` Updating category "${category}" save path from "${currentSavePath}" to "${remoteSavePath}"`); await this.client.post( '/torrents/editCategory', new URLSearchParams({ category, - savePath: this.defaultSavePath, + savePath: remoteSavePath, }), { headers: { @@ -516,7 +520,7 @@ export class QBittorrentService { logger.info(` Category "${category}" save path updated successfully`); } else { - logger.info(` Category "${category}" already has correct save path: ${this.defaultSavePath}`); + logger.info(` Category "${category}" already has correct save path: ${remoteSavePath}`); } } } catch (error) { @@ -527,7 +531,7 @@ export class QBittorrentService { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, - requestedPath: this.defaultSavePath, + requestedPath: remoteSavePath, }); } else { logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) }); diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index 7316fc1..dc9eaca 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -395,6 +395,91 @@ describe('QBittorrentService', () => { expect(clientMock.post).not.toHaveBeenCalled(); }); + it('applies reverse path mapping when creating category', async () => { + const service = new QBittorrentService( + 'http://qb', + 'user', + 'pass', + '/downloads', + 'readmeabook', + false, + { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' } + ); + (service as any).cookie = 'SID=pathmap'; + clientMock.get.mockResolvedValue({ data: {} }); // No existing categories + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await (service as any).ensureCategory('readmeabook'); + + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/createCategory', + expect.any(URLSearchParams), + expect.any(Object) + ); + + // Verify the savePath was reverse transformed (local → remote) + const postCall = clientMock.post.mock.calls[0]; + const params = postCall[1] as URLSearchParams; + expect(params.get('savePath')).toBe('F:\\Docker\\downloads'); + }); + + it('applies reverse path mapping when updating category', async () => { + const service = new QBittorrentService( + 'http://qb', + 'user', + 'pass', + '/downloads', + 'readmeabook', + false, + { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' } + ); + (service as any).cookie = 'SID=pathmap-update'; + // Category exists with old path + clientMock.get.mockResolvedValue({ + data: { + readmeabook: { savePath: 'F:\\OldPath' }, + }, + }); + clientMock.post.mockResolvedValue({ data: 'Ok.' }); + + await (service as any).ensureCategory('readmeabook'); + + expect(clientMock.post).toHaveBeenCalledWith( + '/torrents/editCategory', + expect.any(URLSearchParams), + expect.any(Object) + ); + + // Verify the savePath was reverse transformed (local → remote) + const postCall = clientMock.post.mock.calls[0]; + const params = postCall[1] as URLSearchParams; + expect(params.get('savePath')).toBe('F:\\Docker\\downloads'); + }); + + it('does not update category when remote path already matches', async () => { + const service = new QBittorrentService( + 'http://qb', + 'user', + 'pass', + '/downloads', + 'readmeabook', + false, + { enabled: true, remotePath: 'F:\\Docker\\downloads', localPath: '/downloads' } + ); + (service as any).cookie = 'SID=pathmap-match'; + // Category already has the correct remote path + clientMock.get.mockResolvedValue({ + data: { + readmeabook: { savePath: 'F:\\Docker\\downloads' }, + }, + }); + + await (service as any).ensureCategory('readmeabook'); + + // Should not call post since path already matches + expect(clientMock.post).not.toHaveBeenCalled(); + }); + it('pauses and resumes torrents', async () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); (service as any).cookie = 'SID=pause';