diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 55ff4bf..516c325 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -1091,20 +1091,12 @@ export class QBittorrentService implements IDownloadClient { protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { const status = this.mapStateToDownloadStatus(torrent.state); - // 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, contentBasename) - : (torrent.content_path || path.join(torrent.save_path, torrent.name)); + // content_path is the canonical path from qBittorrent — always use it directly. + // It correctly handles all torrent structures (multi-file folders, single files, + // single files in wrapper folders, name mismatches). + // For TempPathEnabled race detection, we expose save_path so the monitor can + // compare and wait for files to relocate before triggering file organization. + const downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name); return { id: torrent.hash, @@ -1117,6 +1109,7 @@ export class QBittorrentService implements IDownloadClient { eta: torrent.eta, category: torrent.category, downloadPath, + savePath: torrent.save_path, completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined, seedingTime: torrent.seeding_time, ratio: torrent.ratio, diff --git a/src/lib/interfaces/download-client.interface.ts b/src/lib/interfaces/download-client.interface.ts index 9194e4d..60d1900 100644 --- a/src/lib/interfaces/download-client.interface.ts +++ b/src/lib/interfaces/download-client.interface.ts @@ -82,6 +82,8 @@ export interface DownloadInfo { category: string; /** Filesystem path where download is stored (available after completion) */ downloadPath?: string; + /** Configured save directory (torrent clients only, used for path readiness detection) */ + savePath?: string; /** When the download completed */ completedAt?: Date; /** Error message if download failed */ diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index dada097..804ba88 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -32,7 +32,7 @@ function getBackoffDelay(stallCount: number): number { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise { const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId, - lastProgress: prevProgress, stallCount: prevStallCount } = payload; + lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload; const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); @@ -95,6 +95,32 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P throw new Error('Download path not available from download client'); } + // Detect TempPathEnabled race: content_path hasn't been relocated to save_path yet + if (info.savePath && downloadPath) { + const normalizedSave = info.savePath.endsWith('/') ? info.savePath : info.savePath + '/'; + if (!downloadPath.startsWith(normalizedSave)) { + const waitCount = (prevPathWaitCount ?? 0) + 1; + const MAX_PATH_WAIT = 30; // Give up after ~5 minutes + + if (waitCount < MAX_PATH_WAIT) { + const delay = Math.min(10, waitCount * 2); // 2s, 4s, 6s... up to 10s + logger.info(`Download path still in temp location, waiting for relocation (${waitCount}/${MAX_PATH_WAIT})`, { + downloadPath, savePath: info.savePath, + }); + + const jobQueue = getJobQueueService(); + await jobQueue.addMonitorJob( + requestId, downloadHistoryId, downloadClientId, downloadClient, + delay, 100, 0, waitCount + ); + + return { success: true, completed: false, message: 'Waiting for file relocation', pathWaitCount: waitCount }; + } + + logger.warn(`Download path still in temp location after ${waitCount} checks, proceeding with organization`); + } + } + // Get path mapping configuration from the specific download client const clientConfig = await manager.getClientForProtocol(protocol); diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index c99356e..da6157b 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library'; import { getConfigService } from '../services/config.service'; import { getDownloadClientManager } from '../services/download-client-manager.service'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; +import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { generateFilesHash } from '../utils/files-hash'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; @@ -309,6 +310,43 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi // Still have retries left - queue for re-import logger.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`); + // Re-query download client for fresh path (content_path may have been updated since handoff) + try { + const downloadHistory = await prisma.downloadHistory.findFirst({ + where: { requestId }, + orderBy: { createdAt: 'desc' }, + }); + + if (downloadHistory?.downloadClientId && downloadHistory?.downloadClient && downloadHistory.downloadClient !== 'direct') { + const configService = getConfigService(); + const dlManager = getDownloadClientManager(configService); + const dlProtocol = CLIENT_PROTOCOL_MAP[downloadHistory.downloadClient as DownloadClientType]; + + if (dlProtocol) { + const dlClient = await dlManager.getClientServiceForProtocol(dlProtocol); + if (dlClient) { + const freshInfo = await dlClient.getDownload(downloadHistory.downloadClientId); + if (freshInfo?.downloadPath && freshInfo.downloadPath !== downloadPath) { + // Apply path mapping and update stored path + const clientConfig = await dlManager.getClientForProtocol(dlProtocol); + const pathMappingConfig: PathMappingConfig = clientConfig?.remotePathMappingEnabled + ? { enabled: true, remotePath: clientConfig.remotePath || '', localPath: clientConfig.localPath || '' } + : { enabled: false, remotePath: '', localPath: '' }; + const freshPath = PathMapper.transform(freshInfo.downloadPath, pathMappingConfig); + + logger.info(`Download client returned updated path: ${freshPath} (was: ${downloadPath})`); + await prisma.downloadHistory.update({ + where: { id: downloadHistory.id }, + data: { downloadPath: freshPath }, + }); + } + } + } + } + } catch (refreshError) { + logger.warn(`Failed to refresh download path: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); + } + await prisma.request.update({ where: { id: requestId }, data: { diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index ea7c015..76e8680 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -65,6 +65,7 @@ export interface MonitorDownloadPayload extends JobPayload { downloadClient: DownloadClientType; lastProgress?: number; // Previous poll's progress (0-100) for stall detection stallCount?: number; // Consecutive polls with no progress change (drives backoff) + pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path } export interface OrganizeFilesPayload extends JobPayload { @@ -567,7 +568,8 @@ export class JobQueueService { downloadClient: DownloadClientType, delaySeconds: number = 0, lastProgress?: number, - stallCount?: number + stallCount?: number, + pathWaitCount?: number ): Promise { return await this.addJob( 'monitor_download', @@ -578,6 +580,7 @@ export class JobQueueService { downloadClient, lastProgress, stallCount, + pathWaitCount, } as MonitorDownloadPayload, { priority: 5, // Medium priority diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index 45bce8d..ec6fb29 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -329,233 +329,206 @@ describe('QBittorrentService', () => { }); }); - 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({ - 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, - }], + describe('downloadPath resolution', () => { + describe('normal operation (content_path under save_path)', () => { + it('uses content_path directly for seeding multi-file torrent', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=normal-multi'; + 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: '/downloads/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe('/downloads/Audiobook'); + expect(info!.savePath).toBe('/downloads/'); }); - const info = await service.getDownload('abc123'); + it('uses content_path directly for single-file torrent in folder', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=normal-single-folder'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook Name', size: 3700000000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 3700000000, uploaded: 100000, + eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '', + save_path: '/downloads/books/', + content_path: '/downloads/books/Audiobook Folder/Audiobook.m4b', + completion_on: 1700000000, added_on: 1699000000, + }], + }); - expect(info).not.toBeNull(); - expect(info!.status).toBe('seeding'); - // 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'); + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // Must preserve the full path including intermediate folder + expect(info!.downloadPath).toBe('/downloads/books/Audiobook Folder/Audiobook.m4b'); + expect(info!.savePath).toBe('/downloads/books/'); + }); + + it('uses content_path directly when torrent name differs from folder name', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=name-mismatch'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', + name: 'Harry Potter [Full-Cast] (aka 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: '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // Must use content_path (real folder name), NOT torrent.name + expect(info!.downloadPath).toBe( + '/downloads/books/Harry Potter (Full-Cast Edition) EAC3 6ch - J.K. Rowling' + ); + expect(info!.downloadPath).not.toContain('[Full-Cast]'); + expect(info!.savePath).toBe('/downloads/books/'); + }); + + it('uses content_path directly for all seeding states (pausedUP, stalledUP, forcedUP, queuedUP, stoppedUP)', async () => { + const seedingStates = ['pausedUP', 'stalledUP', 'forcedUP', 'queuedUP', 'stoppedUP']; + + for (const state of seedingStates) { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = `SID=state-${state}`; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, + dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100, + eta: 0, state, category: 'readmeabook', tags: '', + save_path: '/downloads/', content_path: '/downloads/Audiobook', + completion_on: 1700000000, added_on: 1699000000, + }], + }); + + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + expect(info!.downloadPath).toBe('/downloads/Audiobook'); + } + }); }); - 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, - }], + describe('TempPathEnabled (content_path outside save_path)', () => { + it('passes through content_path as-is even when pointing to temp dir (monitor handles wait)', 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!.status).toBe('seeding'); + // content_path is always used directly — monitor detects temp path via savePath + expect(info!.downloadPath).toBe('/incomplete/Audiobook'); + expect(info!.savePath).toBe('/downloads/'); }); - const info = await service.getDownload('abc123'); + it('exposes savePath so monitor can detect temp path for pausedUP', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=pausedup-temp'; + 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, + }], + }); - expect(info!.status).toBe('seeding'); - expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('seeding'); + // content_path is always used directly — no reconstruction + expect(info!.downloadPath).toBe('/tmp/incomplete/Audiobook'); + expect(info!.savePath).toBe('/data/torrents/readmeabook/'); + }); }); - 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, - }], + describe('downloading torrents', () => { + 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 as-is (points to where files currently are) + expect(info!.downloadPath).toBe('/incomplete/Audiobook'); }); - const info = await service.getDownload('abc123'); + it('falls back to save_path + name when content_path is empty', 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, + }], + }); - expect(info!.status).toBe('seeding'); - expect(info!.downloadPath).toBe(path.join('/data/torrents/readmeabook/', 'Audiobook')); + const info = await service.getDownload('abc123'); + + expect(info!.status).toBe('downloading'); + expect(info!.downloadPath).toBe(path.join('/downloads/', '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, - }], + describe('empty content_path fallback', () => { + it('falls back to save_path + name for finished torrents with no content_path', 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'); + expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); }); - - 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('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'); }); });