Compare commits

...

4 Commits

Author SHA1 Message Date
kikootwo 547af71de8 Bump package version to 1.0.14
Update package.json version from 1.0.13 to 1.0.14 to reflect a new patch release. No other changes included in this commit.
2026-02-26 12:46:10 -05:00
kikootwo 1b0a80052d Use content_path and add savePath/path-wait
Always use qBittorrent's content_path as the canonical downloadPath and expose savePath on DownloadInfo instead of reconstructing paths from save_path + basename. Add path-waiting logic to the monitor: track consecutive pathWaitCount polls, re-queue the monitor with exponential-ish backoff while content_path remains outside save_path (to handle TempPathEnabled races), and give up after a configurable max attempts. Extend the MonitorDownload payload and JobQueue APIs to carry pathWaitCount. Organize-files processor now attempts to refresh the stored downloadPath from the download client and updates downloadHistory if the client reports a different path (applying path mapping). Update tests to reflect the new behavior and expectations.
2026-02-26 12:45:24 -05:00
kikootwo d38f03b8f4 Bump version to 1.0.13
Update package.json version from 1.0.12 to 1.0.13 to mark a new patch release.
2026-02-26 09:45:45 -05:00
kikootwo dbea15a34f Use content_path basename for finished torrents
When a torrent is finished (seeding/completed), build the download path from save_path combined with the basename of content_path instead of using torrent.name or the full content_path. This fixes a race with qBittorrent's TempPathEnabled (where content_path may still point to the temp dir) and addresses cases where the displayed torrent.name differs from the actual root folder/filename on disk. Added/updated tests to cover the TempPathEnabled race, name-mismatch scenarios, empty content_path fallback, and single-file torrents.
2026-02-26 09:45:23 -05:00
7 changed files with 263 additions and 151 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.12", "version": "1.0.14",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+7 -9
View File
@@ -1091,15 +1091,12 @@ export class QBittorrentService implements IDownloadClient {
protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
const status = this.mapStateToDownloadStatus(torrent.state); const status = this.mapStateToDownloadStatus(torrent.state);
// For completed/seeding torrents, always use save_path (the configured final destination) // content_path is the canonical path from qBittorrent always use it directly.
// rather than content_path. When TempPathEnabled is active in qBittorrent, there is a race // It correctly handles all torrent structures (multi-file folders, single files,
// window where the torrent state transitions to uploading/seeding before the file move from // single files in wrapper folders, name mismatches).
// the temp/incomplete directory to save_path finishes — content_path still references the // For TempPathEnabled race detection, we expose save_path so the monitor can
// stale temp location during this window, causing downstream ENOENT failures. // compare and wait for files to relocate before triggering file organization.
const isFinished = status === 'seeding' || status === 'completed'; const downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
const downloadPath = isFinished
? path.join(torrent.save_path, torrent.name)
: (torrent.content_path || path.join(torrent.save_path, torrent.name));
return { return {
id: torrent.hash, id: torrent.hash,
@@ -1112,6 +1109,7 @@ export class QBittorrentService implements IDownloadClient {
eta: torrent.eta, eta: torrent.eta,
category: torrent.category, category: torrent.category,
downloadPath, downloadPath,
savePath: torrent.save_path,
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined, completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
seedingTime: torrent.seeding_time, seedingTime: torrent.seeding_time,
ratio: torrent.ratio, ratio: torrent.ratio,
@@ -82,6 +82,8 @@ export interface DownloadInfo {
category: string; category: string;
/** Filesystem path where download is stored (available after completion) */ /** Filesystem path where download is stored (available after completion) */
downloadPath?: string; downloadPath?: string;
/** Configured save directory (torrent clients only, used for path readiness detection) */
savePath?: string;
/** When the download completed */ /** When the download completed */
completedAt?: Date; completedAt?: Date;
/** Error message if download failed */ /** Error message if download failed */
@@ -32,7 +32,7 @@ function getBackoffDelay(stallCount: number): number {
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId, const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId,
lastProgress: prevProgress, stallCount: prevStallCount } = payload; lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload;
const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); 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'); 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 // Get path mapping configuration from the specific download client
const clientConfig = await manager.getClientForProtocol(protocol); const clientConfig = await manager.getClientForProtocol(protocol);
@@ -11,6 +11,7 @@ import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service'; import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { generateFilesHash } from '../utils/files-hash'; import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; 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 // Still have retries left - queue for re-import
logger.warn(`Retryable error for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`); 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({ await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
data: { data: {
+4 -1
View File
@@ -65,6 +65,7 @@ export interface MonitorDownloadPayload extends JobPayload {
downloadClient: DownloadClientType; downloadClient: DownloadClientType;
lastProgress?: number; // Previous poll's progress (0-100) for stall detection lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff) 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 { export interface OrganizeFilesPayload extends JobPayload {
@@ -567,7 +568,8 @@ export class JobQueueService {
downloadClient: DownloadClientType, downloadClient: DownloadClientType,
delaySeconds: number = 0, delaySeconds: number = 0,
lastProgress?: number, lastProgress?: number,
stallCount?: number stallCount?: number,
pathWaitCount?: number
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'monitor_download', 'monitor_download',
@@ -578,6 +580,7 @@ export class JobQueueService {
downloadClient, downloadClient,
lastProgress, lastProgress,
stallCount, stallCount,
pathWaitCount,
} as MonitorDownloadPayload, } as MonitorDownloadPayload,
{ {
priority: 5, // Medium priority priority: 5, // Medium priority
+116 -71
View File
@@ -329,8 +329,104 @@ describe('QBittorrentService', () => {
}); });
}); });
describe('downloadPath resolution (TempPathEnabled race condition fix)', () => { describe('downloadPath resolution', () => {
it('uses save_path for seeding torrents even when content_path points to temp dir', async () => { 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/');
});
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,
}],
});
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');
}
});
});
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'); const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=temppath'; (service as any).cookie = 'SID=temppath';
clientMock.get.mockResolvedValueOnce({ clientMock.get.mockResolvedValueOnce({
@@ -345,35 +441,15 @@ describe('QBittorrentService', () => {
const info = await service.getDownload('abc123'); const info = await service.getDownload('abc123');
expect(info).not.toBeNull();
expect(info!.status).toBe('seeding'); expect(info!.status).toBe('seeding');
// Must use save_path + name, NOT the stale content_path // content_path is always used directly — monitor detects temp path via savePath
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); expect(info!.downloadPath).toBe('/incomplete/Audiobook');
expect(info!.downloadPath).not.toContain('incomplete'); expect(info!.savePath).toBe('/downloads/');
}); });
it('uses save_path for stalledUP torrents (completed, stalled on upload)', async () => { it('exposes savePath so monitor can detect temp path for pausedUP', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass'); const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=stalledup'; (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: 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({ clientMock.get.mockResolvedValueOnce({
data: [{ data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
@@ -387,28 +463,13 @@ describe('QBittorrentService', () => {
const info = await service.getDownload('abc123'); const info = await service.getDownload('abc123');
expect(info!.status).toBe('seeding'); expect(info!.status).toBe('seeding');
expect(info!.downloadPath).toBe(path.join('/data/torrents/readmeabook/', 'Audiobook')); // 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 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'));
}); });
describe('downloading torrents', () => {
it('uses content_path for actively downloading torrents', async () => { it('uses content_path for actively downloading torrents', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass'); const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=downloading'; (service as any).cookie = 'SID=downloading';
@@ -425,11 +486,11 @@ describe('QBittorrentService', () => {
const info = await service.getDownload('abc123'); const info = await service.getDownload('abc123');
expect(info!.status).toBe('downloading'); expect(info!.status).toBe('downloading');
// During download, content_path is used (points to where files currently are) // During download, content_path is used as-is (points to where files currently are)
expect(info!.downloadPath).toBe('/incomplete/Audiobook'); expect(info!.downloadPath).toBe('/incomplete/Audiobook');
}); });
it('falls back to save_path + name when content_path is empty during download', async () => { it('falls back to save_path + name when content_path is empty', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass'); const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=nocontent'; (service as any).cookie = 'SID=nocontent';
clientMock.get.mockResolvedValueOnce({ clientMock.get.mockResolvedValueOnce({
@@ -447,35 +508,18 @@ describe('QBittorrentService', () => {
expect(info!.status).toBe('downloading'); expect(info!.status).toBe('downloading');
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); 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'); describe('empty content_path fallback', () => {
it('falls back to save_path + name for finished torrents with no content_path', async () => {
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'); const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=queuedup'; (service as any).cookie = 'SID=nocontent-finished';
clientMock.get.mockResolvedValueOnce({ clientMock.get.mockResolvedValueOnce({
data: [{ data: [{
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0, hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0, dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
eta: 0, state: 'queuedUP', category: 'readmeabook', tags: '', eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
save_path: '/downloads/', content_path: '/incomplete/Audiobook', save_path: '/downloads/', content_path: '',
completion_on: 1700000000, added_on: 1699000000, completion_on: 1700000000, added_on: 1699000000,
}], }],
}); });
@@ -486,6 +530,7 @@ describe('QBittorrentService', () => {
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook')); expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
}); });
}); });
});
it('authenticates and stores a session cookie', async () => { it('authenticates and stores a session cookie', async () => {
axiosMock.post.mockResolvedValue({ axiosMock.post.mockResolvedValue({