mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -8,9 +8,9 @@ import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const qbtMock = vi.hoisted(() => ({
|
||||
getTorrent: vi.fn(),
|
||||
deleteTorrent: vi.fn(),
|
||||
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientServiceForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
@@ -21,8 +21,8 @@ vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: async () => qbtMock,
|
||||
vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
describe('processCleanupSeededTorrents', () => {
|
||||
@@ -66,13 +66,31 @@ describe('processCleanupSeededTorrents', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-1' } });
|
||||
expect(qbtMock.getTorrent).not.toHaveBeenCalled();
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes torrents when seeding requirements are met with no shared downloads', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }])
|
||||
);
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-1',
|
||||
name: 'Torrent',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 1.0,
|
||||
status: 'seeding',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
seedingTime: 60 * 40,
|
||||
}),
|
||||
deleteDownload: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -84,29 +102,43 @@ describe('processCleanupSeededTorrents', () => {
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'IndexerA',
|
||||
torrentHash: 'hash-1',
|
||||
downloadClientId: 'hash-1',
|
||||
downloadClient: 'qbittorrent',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Torrent',
|
||||
seeding_time: 60 * 40,
|
||||
});
|
||||
qbtMock.deleteTorrent.mockResolvedValue({});
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-3' });
|
||||
|
||||
expect(result.cleaned).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
||||
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-1', true);
|
||||
});
|
||||
|
||||
it('keeps shared torrents and deletes soft-deleted request', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 10 }])
|
||||
);
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-2',
|
||||
name: 'Torrent',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 1.0,
|
||||
status: 'seeding',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
seedingTime: 60 * 20,
|
||||
}),
|
||||
deleteDownload: vi.fn(),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -118,16 +150,14 @@ describe('processCleanupSeededTorrents', () => {
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'IndexerA',
|
||||
torrentHash: 'hash-2',
|
||||
downloadClientId: 'hash-2',
|
||||
downloadClient: 'qbittorrent',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'req-4', status: 'downloaded' }]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Torrent',
|
||||
seeding_time: 60 * 20,
|
||||
});
|
||||
prismaMock.request.delete.mockResolvedValue({});
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
@@ -135,7 +165,106 @@ describe('processCleanupSeededTorrents', () => {
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-3' } });
|
||||
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
||||
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up ebook torrents downloaded via indexer', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'EbookIndexer', seedingTimeMinutes: 15 }])
|
||||
);
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-ebook-1',
|
||||
name: 'Equal Rites - Terry Pratchett (epub)',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 1.0,
|
||||
status: 'seeding',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
seedingTime: 60 * 20, // 20 minutes, exceeds 15 min requirement
|
||||
}),
|
||||
deleteDownload: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-ebook-1',
|
||||
type: 'ebook',
|
||||
deletedAt: null,
|
||||
downloadHistory: [
|
||||
{
|
||||
selected: true,
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'EbookIndexer',
|
||||
torrentHash: 'hash-ebook-1',
|
||||
downloadClientId: 'hash-ebook-1',
|
||||
downloadClient: 'qbittorrent',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]); // No shared downloads
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-ebook-1' });
|
||||
|
||||
expect(result.cleaned).toBe(1);
|
||||
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true);
|
||||
});
|
||||
|
||||
it('detects shared torrents across audiobook and ebook requests', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])
|
||||
);
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-shared',
|
||||
name: 'Shared Torrent',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 1.0,
|
||||
status: 'seeding',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
seedingTime: 60 * 30,
|
||||
}),
|
||||
deleteDownload: vi.fn(),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-audio-shared',
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
downloadHistory: [
|
||||
{
|
||||
selected: true,
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'SharedIndexer',
|
||||
torrentHash: 'hash-shared',
|
||||
downloadClientId: 'hash-shared',
|
||||
downloadClient: 'qbittorrent',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
// Shared torrent check finds an ebook request using same hash
|
||||
.mockResolvedValueOnce([{ id: 'req-ebook-shared', status: 'downloading' }]);
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-shared' });
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
||||
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getClientServiceForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
@@ -92,12 +93,18 @@ describe('processDownloadTorrent', () => {
|
||||
};
|
||||
|
||||
it('routes torrent downloads to qBittorrent', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
addDownload: vi.fn().mockResolvedValue('hash-1'),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
category: 'readmeabook',
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
@@ -105,8 +112,8 @@ describe('processDownloadTorrent', () => {
|
||||
const result = await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(qbtMock.addTorrent).toHaveBeenCalled();
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(qbtClientMock.addDownload).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'dh-1',
|
||||
@@ -117,12 +124,18 @@ describe('processDownloadTorrent', () => {
|
||||
});
|
||||
|
||||
it('routes NZB downloads to SABnzbd', async () => {
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
addDownload: vi.fn().mockResolvedValue('nzb-1'),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
category: 'readmeabook',
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
@@ -130,8 +143,8 @@ describe('processDownloadTorrent', () => {
|
||||
const result = await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(sabMock.addNZB).toHaveBeenCalled();
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(sabClientMock.addDownload).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-2',
|
||||
'dh-2',
|
||||
@@ -142,44 +155,57 @@ describe('processDownloadTorrent', () => {
|
||||
});
|
||||
|
||||
it('throws error when no client configured for protocol', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue(null);
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
|
||||
await expect(processDownloadTorrent(torrentPayload)).rejects.toThrow(
|
||||
'No Torrent (qBittorrent) client configured'
|
||||
'No torrent download client configured'
|
||||
);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
|
||||
});
|
||||
|
||||
it('detects protocol from result and routes appropriately', async () => {
|
||||
// Torrent result
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
addDownload: vi.fn().mockResolvedValue('hash-1'),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
enabled: true,
|
||||
category: 'readmeabook',
|
||||
});
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
await processDownloadTorrent(torrentPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('torrent');
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('torrent');
|
||||
|
||||
// NZB result
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
addDownload: vi.fn().mockResolvedValue('nzb-1'),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValueOnce(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValueOnce({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
enabled: true,
|
||||
category: 'readmeabook',
|
||||
});
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
await processDownloadTorrent(nzbPayload);
|
||||
|
||||
expect(downloadClientManagerMock.getClientForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ const configMock = vi.hoisted(() => ({
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getClientServiceForProtocol: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
@@ -50,20 +51,27 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
describe('processMonitorDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('queues organize job when qBittorrent download completes', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
content_path: '/remote/done/Book',
|
||||
save_path: '/remote/done',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 100,
|
||||
state: 'completed',
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
});
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-1',
|
||||
name: 'Book',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
downloadPath: '/remote/done/Book',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -96,19 +104,34 @@ describe('processMonitorDownload', () => {
|
||||
'a1',
|
||||
expect.stringMatching(/downloads[\\/]+Book/)
|
||||
);
|
||||
// Verify downloadPath is stored in download history on completion
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'completed',
|
||||
downloadPath: expect.stringMatching(/downloads[\\/]+Book/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('re-schedules monitoring when download is still active', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 45,
|
||||
state: 'downloading',
|
||||
speed: 100,
|
||||
eta: 60,
|
||||
});
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-2',
|
||||
name: 'Book',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0.45,
|
||||
status: 'downloading',
|
||||
downloadSpeed: 100,
|
||||
eta: 60,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
@@ -132,18 +155,29 @@ describe('processMonitorDownload', () => {
|
||||
});
|
||||
|
||||
it('marks request failed when download fails', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 20,
|
||||
state: 'failed',
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
});
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-3',
|
||||
name: 'Book',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
progress: 0.20,
|
||||
status: 'failed',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-3',
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
@@ -163,15 +197,23 @@ describe('processMonitorDownload', () => {
|
||||
});
|
||||
|
||||
it('handles SABnzbd completion and queues organize job', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-1',
|
||||
size: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
timeLeft: 0,
|
||||
downloadPath: '/usenet/complete/Book',
|
||||
});
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'nzb-1',
|
||||
name: 'Book',
|
||||
size: 100,
|
||||
bytesDownloaded: 100,
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
downloadPath: '/usenet/complete/Book',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
@@ -202,10 +244,76 @@ describe('processMonitorDownload', () => {
|
||||
'a4',
|
||||
'/usenet/complete/Book'
|
||||
);
|
||||
// Verify downloadPath is stored in download history on completion
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
downloadStatus: 'completed',
|
||||
downloadPath: '/usenet/complete/Book',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles NZBGet completion and queues organize job', async () => {
|
||||
const nzbgetClientMock = {
|
||||
clientType: 'nzbget',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: '42',
|
||||
name: 'Book',
|
||||
size: 200,
|
||||
bytesDownloaded: 200,
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
downloadPath: '/downloads/readmeabook/Book',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(nzbgetClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-nzbget',
|
||||
type: 'nzbget',
|
||||
name: 'NZBGet',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-nzbget',
|
||||
audiobook: { id: 'a-nzbget' },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-nzbget',
|
||||
downloadHistoryId: 'dh-nzbget',
|
||||
downloadClientId: '42',
|
||||
downloadClient: 'nzbget',
|
||||
jobId: 'job-nzbget',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
// Verify it called getClientServiceForProtocol with 'usenet' (not 'torrent')
|
||||
expect(downloadClientManagerMock.getClientServiceForProtocol).toHaveBeenCalledWith('usenet');
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-nzbget',
|
||||
'a-nzbget',
|
||||
'/downloads/readmeabook/Book'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark request failed for transient NZB not found errors', async () => {
|
||||
sabMock.getNZB.mockResolvedValue(null);
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
@@ -220,7 +328,13 @@ describe('processMonitorDownload', () => {
|
||||
});
|
||||
|
||||
it('marks request failed when download client is unsupported', async () => {
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(null);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-6',
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
@@ -229,7 +343,7 @@ describe('processMonitorDownload', () => {
|
||||
downloadClientId: 'id-6',
|
||||
downloadClient: 'deluge',
|
||||
jobId: 'job-6',
|
||||
})).rejects.toThrow(/not supported/i);
|
||||
})).rejects.toThrow(/Unknown download client type: deluge/);
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -239,17 +353,37 @@ describe('processMonitorDownload', () => {
|
||||
});
|
||||
|
||||
it('marks request failed when SABnzbd completion lacks a download path', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-2',
|
||||
size: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
timeLeft: 0,
|
||||
downloadPath: undefined,
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'nzb-2',
|
||||
name: 'Book',
|
||||
size: 100,
|
||||
bytesDownloaded: 100,
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
downloadPath: undefined,
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
name: 'SABnzbd',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-7',
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
@@ -268,14 +402,22 @@ describe('processMonitorDownload', () => {
|
||||
});
|
||||
|
||||
it('converts SABnzbd progress from 0.0-1.0 to 0-100 percentage', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-3',
|
||||
size: 1000000000, // 1GB
|
||||
progress: 0.35, // 35% in decimal format (0.0-1.0)
|
||||
status: 'downloading',
|
||||
downloadSpeed: 5000000, // 5MB/s
|
||||
timeLeft: 130,
|
||||
});
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'nzb-3',
|
||||
name: 'Book',
|
||||
size: 1000000000,
|
||||
bytesDownloaded: 350000000,
|
||||
progress: 0.35,
|
||||
status: 'downloading',
|
||||
downloadSpeed: 5000000,
|
||||
eta: 130,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
|
||||
@@ -346,6 +346,58 @@ describe('processOrganizeFiles', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('queues retry when organizer returns EPERM copy failure', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a-eperm',
|
||||
title: 'Theo of Golden',
|
||||
author: 'Allen Levi',
|
||||
narrator: null,
|
||||
coverArtUrl: null,
|
||||
audibleAsin: 'B0FTT6KFKR',
|
||||
});
|
||||
// Organizer returns success: false with EPERM error (the fixed behavior)
|
||||
organizerMock.organize.mockResolvedValue({
|
||||
success: false,
|
||||
targetPath: '/media/audiobooks/Fiction/Allen Levi/Theo of Golden B0FTT6KFKR',
|
||||
filesMovedCount: 0,
|
||||
errors: [
|
||||
'Failed to copy Theo of Golden [B0FTT6KFKR].m4b: EPERM: operation not permitted, copyfile',
|
||||
'No audio files were successfully copied to the target directory',
|
||||
],
|
||||
audioFiles: [],
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
importAttempts: 0,
|
||||
maxImportRetries: 3,
|
||||
deletedAt: null,
|
||||
});
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobook_path_template') return '{author}/{title} {asin}';
|
||||
return null;
|
||||
});
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
requestId: 'req-eperm',
|
||||
audiobookId: 'a-eperm',
|
||||
downloadPath: '/data/torrents/bookbit',
|
||||
jobId: 'job-eperm',
|
||||
});
|
||||
|
||||
// Should be identified as retryable and queued for re-import
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_import',
|
||||
importAttempts: 1,
|
||||
errorMessage: expect.stringContaining('EPERM'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('generates and stores filesHash after successful organization', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -15,9 +15,8 @@ const configMock = vi.hoisted(() => ({
|
||||
}));
|
||||
const downloadClientManagerMock = vi.hoisted(() => ({
|
||||
getClientForProtocol: vi.fn(),
|
||||
getClientServiceForProtocol: vi.fn(),
|
||||
}));
|
||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -35,20 +34,29 @@ vi.mock('@/lib/services/download-client-manager.service', () => ({
|
||||
getDownloadClientManager: () => downloadClientManagerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabnzbdMock,
|
||||
}));
|
||||
|
||||
describe('processRetryFailedImports', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queues organize jobs using download client paths', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-1',
|
||||
name: 'Book',
|
||||
downloadPath: '/downloads/Book',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -65,11 +73,6 @@ describe('processRetryFailedImports', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-1' });
|
||||
|
||||
@@ -109,6 +112,12 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -128,8 +137,6 @@ describe('processRetryFailedImports', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-3' });
|
||||
|
||||
@@ -142,6 +149,23 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('uses SABnzbd download path when available', async () => {
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'nzb-1',
|
||||
name: 'Book',
|
||||
downloadPath: '/remote/nzb/Book',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
@@ -159,8 +183,6 @@ describe('processRetryFailedImports', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' });
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-4' });
|
||||
|
||||
@@ -173,6 +195,12 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when download dir is missing', async () => {
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
@@ -189,8 +217,6 @@ describe('processRetryFailedImports', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockResolvedValue(null);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-5' });
|
||||
|
||||
@@ -222,6 +248,23 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('tracks skipped requests when organize job fails', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-7',
|
||||
name: 'Book',
|
||||
downloadPath: '/downloads/Book',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -236,7 +279,6 @@ describe('processRetryFailedImports', () => {
|
||||
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
|
||||
jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
@@ -247,6 +289,12 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -261,7 +309,6 @@ describe('processRetryFailedImports', () => {
|
||||
downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-8' });
|
||||
@@ -272,6 +319,12 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('not found')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
@@ -287,7 +340,6 @@ describe('processRetryFailedImports', () => {
|
||||
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-9' });
|
||||
@@ -297,6 +349,12 @@ describe('processRetryFailedImports', () => {
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when the client throws', async () => {
|
||||
const sabClientMock = {
|
||||
clientType: 'sabnzbd',
|
||||
protocol: 'usenet',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('sab down')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(sabClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-2',
|
||||
type: 'sabnzbd',
|
||||
@@ -312,8 +370,6 @@ describe('processRetryFailedImports', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-10' });
|
||||
|
||||
@@ -321,6 +377,138 @@ describe('processRetryFailedImports', () => {
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('uses stored downloadPath when client throws', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-stored-1',
|
||||
audiobook: { id: 'a-stored-1', title: 'Freefall' },
|
||||
downloadHistory: [{
|
||||
torrentHash: 'hash-stored-1',
|
||||
torrentName: 'Freefall: Expeditionary Force Mavericks, Book 2 - Craig Alanson',
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadPath: '/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-stored-1' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
// Should use stored path, NOT the torrentName-based fallback
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-stored-1',
|
||||
'a-stored-1',
|
||||
'/downloads/Craig Alanson - Freefall Expeditionary Force Mavericks, Book 2'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to torrentName when stored downloadPath is null', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(new Error('torrent removed')),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
configMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-stored-2',
|
||||
audiobook: { id: 'a-stored-2', title: 'Book' },
|
||||
downloadHistory: [{
|
||||
torrentHash: 'hash-stored-2',
|
||||
torrentName: 'Book',
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadPath: null, // Old record without stored path
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-stored-2' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
// Should fall back to torrentName-based path
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-stored-2',
|
||||
'a-stored-2',
|
||||
'/downloads/Book'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers live client path over stored downloadPath', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-stored-3',
|
||||
name: 'Book',
|
||||
downloadPath: '/downloads/LivePath',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-stored-3',
|
||||
audiobook: { id: 'a-stored-3', title: 'Book' },
|
||||
downloadHistory: [{
|
||||
torrentHash: 'hash-stored-3',
|
||||
torrentName: 'Book',
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadPath: '/downloads/StoredPath',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-stored-3' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
// Should use live client path, NOT stored path
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-stored-3',
|
||||
'a-stored-3',
|
||||
'/downloads/LivePath'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips requests without download_dir when no client identifiers exist', async () => {
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
@@ -344,6 +532,226 @@ describe('processRetryFailedImports', () => {
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// EBOOK REQUEST TESTS
|
||||
// =========================================================================
|
||||
|
||||
it('retries ebook requests with direct download client using stored path', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-ebook-1',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-ebook-1', title: 'Equal Rites' },
|
||||
downloadHistory: [{
|
||||
downloadClient: 'direct',
|
||||
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
||||
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-ebook-1' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-ebook-1',
|
||||
'a-ebook-1',
|
||||
'/downloads/Equal Rites - Terry Pratchett.epub'
|
||||
);
|
||||
});
|
||||
|
||||
it('retries ebook requests with direct download using fallback path', async () => {
|
||||
configMock.get.mockResolvedValue('/downloads');
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-ebook-2',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-ebook-2', title: 'Equal Rites' },
|
||||
downloadHistory: [{
|
||||
downloadClient: 'direct',
|
||||
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
||||
downloadPath: null, // No stored path
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-ebook-2' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-ebook-2',
|
||||
'a-ebook-2',
|
||||
'/downloads/Equal Rites - Terry Pratchett.epub'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips direct ebook requests when no stored path and no download_dir', async () => {
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-ebook-3',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-ebook-3', title: 'Equal Rites' },
|
||||
downloadHistory: [{
|
||||
downloadClient: 'direct',
|
||||
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
||||
downloadPath: null,
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-ebook-3' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries ebook requests downloaded via indexer (torrent client)', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-ebook-idx',
|
||||
name: 'Equal Rites - Terry Pratchett (epub)',
|
||||
downloadPath: '/downloads/Equal Rites - Terry Pratchett (epub)',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-ebook-idx',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-ebook-idx', title: 'Equal Rites' },
|
||||
downloadHistory: [{
|
||||
torrentHash: 'hash-ebook-idx',
|
||||
torrentName: 'Equal Rites - Terry Pratchett (epub)',
|
||||
downloadClient: 'qbittorrent',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-ebook-idx' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-ebook-idx',
|
||||
'a-ebook-idx',
|
||||
'/downloads/Equal Rites - Terry Pratchett (epub)'
|
||||
);
|
||||
});
|
||||
|
||||
it('processes mixed audiobook and ebook requests in same batch', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockResolvedValue({
|
||||
id: 'hash-audio',
|
||||
name: 'Gideon the Ninth',
|
||||
downloadPath: '/downloads/Gideon the Ninth',
|
||||
progress: 1.0,
|
||||
status: 'completed',
|
||||
size: 0,
|
||||
bytesDownloaded: 0,
|
||||
downloadSpeed: 0,
|
||||
eta: 0,
|
||||
category: 'readmeabook',
|
||||
}),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
|
||||
id: 'client-1',
|
||||
type: 'qbittorrent',
|
||||
name: 'qBittorrent',
|
||||
enabled: true,
|
||||
remotePathMappingEnabled: false,
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-mixed-audio',
|
||||
type: 'audiobook',
|
||||
audiobook: { id: 'a-mixed-audio', title: 'Gideon the Ninth' },
|
||||
downloadHistory: [{
|
||||
torrentHash: 'hash-audio',
|
||||
torrentName: 'Gideon the Ninth',
|
||||
downloadClient: 'qbittorrent',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'req-mixed-ebook',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-mixed-ebook', title: 'Equal Rites' },
|
||||
downloadHistory: [{
|
||||
downloadClient: 'direct',
|
||||
torrentName: 'Equal Rites - Terry Pratchett.epub',
|
||||
downloadPath: '/downloads/Equal Rites - Terry Pratchett.epub',
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-mixed' });
|
||||
|
||||
expect(result.triggered).toBe(2);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledTimes(2);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-mixed-audio',
|
||||
'a-mixed-audio',
|
||||
'/downloads/Gideon the Ninth'
|
||||
);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-mixed-ebook',
|
||||
'a-mixed-ebook',
|
||||
'/downloads/Equal Rites - Terry Pratchett.epub'
|
||||
);
|
||||
});
|
||||
|
||||
it('skips direct ebook requests with no torrentName and no stored path', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-ebook-noname',
|
||||
type: 'ebook',
|
||||
audiobook: { id: 'a-ebook-noname', title: 'Book' },
|
||||
downloadHistory: [{
|
||||
downloadClient: 'direct',
|
||||
torrentName: null,
|
||||
downloadPath: null,
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-ebook-noname' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user