From 33c2265e569967de82ba6901ca4a8559919d16b6 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 24 Feb 2026 02:03:20 -0500 Subject: [PATCH] Use save_path for completed/seeding torrents Resolve downloadPath using save_path for finished torrents to avoid TempPathEnabled race conditions where content_path can point to a stale temp location. Compute status once, treat 'seeding'/'completed' as finished, and prefer path.join(save_path, name) for those states while still using content_path (or falling back to save_path) for active downloads. Added tests covering multiple qBittorrent states (seeding/stalledUP/pausedUP/stoppedUP/forcedUP/queuedUP/downloading and empty content_path) and imported path in tests. --- src/lib/integrations/qbittorrent.service.ts | 16 +- .../integrations/qbittorrent.service.test.ts | 159 ++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 7a53447..2b16015 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -1089,17 +1089,29 @@ export class QBittorrentService implements IDownloadClient { * Map a TorrentInfo object to the unified DownloadInfo format. */ protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { + const status = this.mapStateToDownloadStatus(torrent.state); + + // For completed/seeding torrents, always use save_path (the configured final destination) + // rather than content_path. When TempPathEnabled is active in qBittorrent, there is a race + // window where the torrent state transitions to uploading/seeding before the file move from + // the temp/incomplete directory to save_path finishes — content_path still references the + // stale temp location during this window, causing downstream ENOENT failures. + const isFinished = status === 'seeding' || status === 'completed'; + const downloadPath = isFinished + ? path.join(torrent.save_path, torrent.name) + : (torrent.content_path || path.join(torrent.save_path, torrent.name)); + return { id: torrent.hash, name: torrent.name, size: torrent.size, bytesDownloaded: torrent.downloaded, progress: torrent.progress, - status: this.mapStateToDownloadStatus(torrent.state), + status, downloadSpeed: torrent.dlspeed, eta: torrent.eta, category: torrent.category, - downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name), + downloadPath, completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined, seedingTime: torrent.seeding_time, ratio: torrent.ratio, diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index 29bdedc..2bcd9be 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -3,6 +3,7 @@ * Documentation: documentation/phase3/qbittorrent.md */ +import path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service'; @@ -328,6 +329,164 @@ describe('QBittorrentService', () => { }); }); + describe('downloadPath resolution (TempPathEnabled race condition fix)', () => { + it('uses save_path for seeding torrents even when content_path points to temp dir', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=temppath'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500, + eta: 0, state: 'uploading', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('seeding'); + // Must use save_path + name, NOT the stale content_path + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + expect(info!.downloadPath).not.toContain('incomplete'); + }); + + it('uses save_path for stalledUP torrents (completed, stalled on upload)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=stalledup'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200, + eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + + it('uses save_path for pausedUP torrents (completed, paused on upload)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=pausedup2'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0, + eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '', + save_path: '/data/torrents/readmeabook/', content_path: '/tmp/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe(path.join('/data/torrents/readmeabook/', 'Audiobook')); + }); + + it('uses save_path for stoppedUP torrents (qBittorrent v5.x completed)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=stoppedup2'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100, + eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + + it('uses content_path for actively downloading torrents', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=downloading'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5, + dlspeed: 5000, upspeed: 0, downloaded: 500, uploaded: 0, + eta: 100, state: 'downloading', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('downloading'); + // During download, content_path is used (points to where files currently are) + expect(info!.downloadPath).toBe('/incomplete/Audiobook'); + }); + + it('falls back to save_path + name when content_path is empty during download', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=nocontent'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.3, + dlspeed: 1000, upspeed: 0, downloaded: 300, uploaded: 0, + eta: 700, state: 'downloading', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '', + completion_on: 0, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('downloading'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + + it('uses save_path for forcedUP torrents (force-resumed seeding)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=forcedup2'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 10000, downloaded: 1000, uploaded: 2000, + eta: 0, state: 'forcedUP', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + + it('uses save_path for queuedUP torrents (completed, queued for upload)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=queuedup'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0, + eta: 0, state: 'queuedUP', category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/incomplete/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + }); + it('authenticates and stores a session cookie', async () => { axiosMock.post.mockResolvedValue({ status: 200,