diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 2b16015..55ff4bf 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -1091,14 +1091,19 @@ export class QBittorrentService implements IDownloadClient { 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. + // For completed/seeding torrents, combine save_path with the content folder basename. + // Two problems are solved simultaneously: + // 1. TempPathEnabled race — content_path may still reference the temp/incomplete directory + // after qBittorrent marks the torrent as seeding but before the file move finishes. + // 2. Name mismatch — torrent.name (display name) can differ from the actual folder name + // on disk (the root folder inside the torrent archive). content_path always reflects + // the real filesystem name, so we extract its basename for the join. const isFinished = status === 'seeding' || status === 'completed'; + const contentBasename = torrent.content_path + ? path.basename(torrent.content_path) + : torrent.name; const downloadPath = isFinished - ? path.join(torrent.save_path, torrent.name) + ? path.join(torrent.save_path, contentBasename) : (torrent.content_path || path.join(torrent.save_path, torrent.name)); return { diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index 2bcd9be..45bce8d 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -329,8 +329,8 @@ 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 () => { + describe('downloadPath resolution (TempPathEnabled race + name mismatch fix)', () => { + it('uses save_path + content basename 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({ @@ -347,7 +347,7 @@ describe('QBittorrentService', () => { expect(info).not.toBeNull(); expect(info!.status).toBe('seeding'); - // Must use save_path + name, NOT the stale content_path + // Must use save_path + content_path basename, NOT the stale full content_path expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); expect(info!.downloadPath).not.toContain('incomplete'); }); @@ -485,6 +485,78 @@ describe('QBittorrentService', () => { expect(info!.status).toBe('seeding'); expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); }); + + it('uses content_path basename when torrent name differs from folder name on disk', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=namemismatch'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', + name: 'Harry Potter and the Sorcerers Stone [Full-Cast] (aka Harry Potter and the Philosophers Stone) - J.K. Rowling', + size: 3006477107, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 3006477107, uploaded: 500000, + eta: 0, state: 'uploading', category: 'readmeabook', tags: '', + save_path: '/downloads/books/', + content_path: '/incomplete/Harry Potter and the Sorcerers Stone (Full-Cast Edition) EAC3+Atmos 6ch - J.K. Rowling', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // Must use the content_path basename (actual folder on disk), NOT torrent.name + expect(info!.downloadPath).toBe( + path.join('/downloads/books/', 'Harry Potter and the Sorcerers Stone (Full-Cast Edition) EAC3+Atmos 6ch - J.K. Rowling') + ); + // Must NOT use the torrent name (which differs from the real folder) + expect(info!.downloadPath).not.toContain('[Full-Cast]'); + expect(info!.downloadPath).not.toContain('incomplete'); + }); + + it('falls back to torrent name when content_path is empty for finished torrents', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=nocontent-finished'; + 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: '/downloads/', content_path: '', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // With no content_path, falls back to torrent name + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + }); + + it('uses content_path basename for single-file torrent where name differs', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=singlefile'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', + name: 'My Audiobook - Special Edition', + size: 500000000, progress: 1.0, + dlspeed: 0, upspeed: 1000, downloaded: 500000000, uploaded: 100000, + eta: 0, state: 'uploading', category: 'readmeabook', tags: '', + save_path: '/downloads/books/', + content_path: '/incomplete/My Audiobook.m4b', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // Single file: basename is the filename itself + expect(info!.downloadPath).toBe(path.join('/downloads/books/', 'My Audiobook.m4b')); + expect(info!.downloadPath).not.toContain('Special Edition'); + }); }); it('authenticates and stores a session cookie', async () => {