mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -32,7 +32,7 @@ function getBackoffDelay(stallCount: number): number {
|
||||
|
||||
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<string> {
|
||||
return await this.addJob(
|
||||
'monitor_download',
|
||||
@@ -578,6 +580,7 @@ export class JobQueueService {
|
||||
downloadClient,
|
||||
lastProgress,
|
||||
stallCount,
|
||||
pathWaitCount,
|
||||
} as MonitorDownloadPayload,
|
||||
{
|
||||
priority: 5, // Medium priority
|
||||
|
||||
Reference in New Issue
Block a user