mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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.
This commit is contained in:
@@ -1091,14 +1091,19 @@ 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)
|
// For completed/seeding torrents, combine save_path with the content folder basename.
|
||||||
// rather than content_path. When TempPathEnabled is active in qBittorrent, there is a race
|
// Two problems are solved simultaneously:
|
||||||
// window where the torrent state transitions to uploading/seeding before the file move from
|
// 1. TempPathEnabled race — content_path may still reference the temp/incomplete directory
|
||||||
// the temp/incomplete directory to save_path finishes — content_path still references the
|
// after qBittorrent marks the torrent as seeding but before the file move finishes.
|
||||||
// stale temp location during this window, causing downstream ENOENT failures.
|
// 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 isFinished = status === 'seeding' || status === 'completed';
|
||||||
|
const contentBasename = torrent.content_path
|
||||||
|
? path.basename(torrent.content_path)
|
||||||
|
: torrent.name;
|
||||||
const downloadPath = isFinished
|
const downloadPath = isFinished
|
||||||
? path.join(torrent.save_path, torrent.name)
|
? path.join(torrent.save_path, contentBasename)
|
||||||
: (torrent.content_path || path.join(torrent.save_path, torrent.name));
|
: (torrent.content_path || path.join(torrent.save_path, torrent.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -329,8 +329,8 @@ describe('QBittorrentService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('downloadPath resolution (TempPathEnabled race condition fix)', () => {
|
describe('downloadPath resolution (TempPathEnabled race + name mismatch fix)', () => {
|
||||||
it('uses save_path for seeding torrents even when content_path points to temp dir', async () => {
|
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');
|
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({
|
||||||
@@ -347,7 +347,7 @@ describe('QBittorrentService', () => {
|
|||||||
|
|
||||||
expect(info).not.toBeNull();
|
expect(info).not.toBeNull();
|
||||||
expect(info!.status).toBe('seeding');
|
expect(info!.status).toBe('seeding');
|
||||||
// Must use save_path + name, NOT the stale content_path
|
// Must use save_path + content_path basename, NOT the stale full content_path
|
||||||
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
||||||
expect(info!.downloadPath).not.toContain('incomplete');
|
expect(info!.downloadPath).not.toContain('incomplete');
|
||||||
});
|
});
|
||||||
@@ -485,6 +485,78 @@ describe('QBittorrentService', () => {
|
|||||||
expect(info!.status).toBe('seeding');
|
expect(info!.status).toBe('seeding');
|
||||||
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses content_path basename when torrent name differs from folder name on disk', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
|
(service as any).cookie = 'SID=namemismatch';
|
||||||
|
clientMock.get.mockResolvedValueOnce({
|
||||||
|
data: [{
|
||||||
|
hash: 'abc123',
|
||||||
|
name: 'Harry Potter and the Sorcerers Stone [Full-Cast] (aka Harry Potter and the Philosophers Stone) - J.K. Rowling',
|
||||||
|
size: 3006477107, progress: 1.0,
|
||||||
|
dlspeed: 0, upspeed: 0, downloaded: 3006477107, uploaded: 500000,
|
||||||
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
||||||
|
save_path: '/downloads/books/',
|
||||||
|
content_path: '/incomplete/Harry Potter and the Sorcerers Stone (Full-Cast Edition) EAC3+Atmos 6ch - J.K. Rowling',
|
||||||
|
completion_on: 1700000000, added_on: 1699000000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await service.getDownload('abc123');
|
||||||
|
|
||||||
|
expect(info!.status).toBe('seeding');
|
||||||
|
// Must use the content_path basename (actual folder on disk), NOT torrent.name
|
||||||
|
expect(info!.downloadPath).toBe(
|
||||||
|
path.join('/downloads/books/', 'Harry Potter and the Sorcerers Stone (Full-Cast Edition) EAC3+Atmos 6ch - J.K. Rowling')
|
||||||
|
);
|
||||||
|
// Must NOT use the torrent name (which differs from the real folder)
|
||||||
|
expect(info!.downloadPath).not.toContain('[Full-Cast]');
|
||||||
|
expect(info!.downloadPath).not.toContain('incomplete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to torrent name when content_path is empty for finished torrents', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
|
(service as any).cookie = 'SID=nocontent-finished';
|
||||||
|
clientMock.get.mockResolvedValueOnce({
|
||||||
|
data: [{
|
||||||
|
hash: 'abc123', name: 'Audiobook', size: 1000, progress: 1.0,
|
||||||
|
dlspeed: 0, upspeed: 0, downloaded: 1000, uploaded: 0,
|
||||||
|
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
|
||||||
|
save_path: '/downloads/', content_path: '',
|
||||||
|
completion_on: 1700000000, added_on: 1699000000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await service.getDownload('abc123');
|
||||||
|
|
||||||
|
expect(info!.status).toBe('seeding');
|
||||||
|
// With no content_path, falls back to torrent name
|
||||||
|
expect(info!.downloadPath).toBe(path.join('/downloads/', 'Audiobook'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses content_path basename for single-file torrent where name differs', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||||
|
(service as any).cookie = 'SID=singlefile';
|
||||||
|
clientMock.get.mockResolvedValueOnce({
|
||||||
|
data: [{
|
||||||
|
hash: 'abc123',
|
||||||
|
name: 'My Audiobook - Special Edition',
|
||||||
|
size: 500000000, progress: 1.0,
|
||||||
|
dlspeed: 0, upspeed: 1000, downloaded: 500000000, uploaded: 100000,
|
||||||
|
eta: 0, state: 'uploading', category: 'readmeabook', tags: '',
|
||||||
|
save_path: '/downloads/books/',
|
||||||
|
content_path: '/incomplete/My Audiobook.m4b',
|
||||||
|
completion_on: 1700000000, added_on: 1699000000,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await service.getDownload('abc123');
|
||||||
|
|
||||||
|
expect(info!.status).toBe('seeding');
|
||||||
|
// Single file: basename is the filename itself
|
||||||
|
expect(info!.downloadPath).toBe(path.join('/downloads/books/', 'My Audiobook.m4b'));
|
||||||
|
expect(info!.downloadPath).not.toContain('Special Edition');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authenticates and stores a session cookie', async () => {
|
it('authenticates and stores a session cookie', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user