mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 547af71de8 | |||
| 1b0a80052d |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.13",
|
"version": "1.0.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -1091,20 +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, combine save_path with the content folder basename.
|
// content_path is the canonical path from qBittorrent — always use it directly.
|
||||||
// Two problems are solved simultaneously:
|
// It correctly handles all torrent structures (multi-file folders, single files,
|
||||||
// 1. TempPathEnabled race — content_path may still reference the temp/incomplete directory
|
// single files in wrapper folders, name mismatches).
|
||||||
// after qBittorrent marks the torrent as seeding but before the file move finishes.
|
// For TempPathEnabled race detection, we expose save_path so the monitor can
|
||||||
// 2. Name mismatch — torrent.name (display name) can differ from the actual folder name
|
// compare and wait for files to relocate before triggering file organization.
|
||||||
// on disk (the root folder inside the torrent archive). content_path always reflects
|
const downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
|
||||||
// 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));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: torrent.hash,
|
id: torrent.hash,
|
||||||
@@ -1117,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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -329,233 +329,206 @@ describe('QBittorrentService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('downloadPath resolution (TempPathEnabled race + name mismatch fix)', () => {
|
describe('downloadPath resolution', () => {
|
||||||
it('uses save_path + content basename for seeding torrents even when content_path points to temp dir', async () => {
|
describe('normal operation (content_path under save_path)', () => {
|
||||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
it('uses content_path directly for seeding multi-file torrent', async () => {
|
||||||
(service as any).cookie = 'SID=temppath';
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
clientMock.get.mockResolvedValueOnce({
|
(service as any).cookie = 'SID=normal-multi';
|
||||||
data: [{
|
clientMock.get.mockResolvedValueOnce({
|
||||||
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
data: [{
|
||||||
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
||||||
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
||||||
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
||||||
completion_on: 1700000000, added_on: 1699000000,
|
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();
|
const info = await service.getDownload('abc123');
|
||||||
expect(info!.status).toBe('seeding');
|
|
||||||
// Must use save_path + content_path basename, NOT the stale full content_path
|
expect(info!.status).toBe('seeding');
|
||||||
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
// Must preserve the full path including intermediate folder
|
||||||
expect(info!.downloadPath).not.toContain('incomplete');
|
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 () => {
|
describe('TempPathEnabled (content_path outside save_path)', () => {
|
||||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
it('passes through content_path as-is even when pointing to temp dir (monitor handles wait)', async () => {
|
||||||
(service as any).cookie = 'SID=stalledup';
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
clientMock.get.mockResolvedValueOnce({
|
(service as any).cookie = 'SID=temppath';
|
||||||
data: [{
|
clientMock.get.mockResolvedValueOnce({
|
||||||
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
data: [{
|
||||||
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 200,
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
||||||
eta: 0, state: 'stalledUP', category: 'readmeabook', tags: '',
|
dlspeed: 0, upspeed: 5000, downloaded: 1000, uploaded: 500,
|
||||||
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
||||||
completion_on: 1700000000, added_on: 1699000000,
|
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');
|
const info = await service.getDownload('abc123');
|
||||||
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
|
||||||
|
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 () => {
|
describe('downloading torrents', () => {
|
||||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
it('uses content_path for actively downloading torrents', async () => {
|
||||||
(service as any).cookie = 'SID=pausedup2';
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
clientMock.get.mockResolvedValueOnce({
|
(service as any).cookie = 'SID=downloading';
|
||||||
data: [{
|
clientMock.get.mockResolvedValueOnce({
|
||||||
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
data: [{
|
||||||
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 0.5,
|
||||||
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
dlspeed: 5000, upspeed: 0, downloaded: 500, uploaded: 0,
|
||||||
save_path: '/data/torrents/readmeabook/', content_path: '/tmp/incomplete/Audiobook',
|
eta: 100, state: 'downloading', category: 'readmeabook', tags: '',
|
||||||
completion_on: 1700000000, added_on: 1699000000,
|
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');
|
const info = await service.getDownload('abc123');
|
||||||
expect(info!.downloadPath).toBe(path.join('/data/torrents/readmeabook/', 'Audiobook'));
|
|
||||||
|
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 () => {
|
describe('empty content_path fallback', () => {
|
||||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
it('falls back to save_path + name for finished torrents with no content_path', async () => {
|
||||||
(service as any).cookie = 'SID=stoppedup2';
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
clientMock.get.mockResolvedValueOnce({
|
(service as any).cookie = 'SID=nocontent-finished';
|
||||||
data: [{
|
clientMock.get.mockResolvedValueOnce({
|
||||||
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
data: [{
|
||||||
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 100,
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
||||||
eta: 0, state: 'stoppedUP', category: 'readmeabook', tags: '',
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
|
||||||
save_path: '/downloads/', content_path: '/incomplete/Audiobook',
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
||||||
completion_on: 1700000000, added_on: 1699000000,
|
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user