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';