Merge branch 'ebook-piecewise'

This commit is contained in:
kikootwo
2026-02-03 10:47:06 -05:00
68 changed files with 7451 additions and 30862 deletions
+79 -35
View File
@@ -20,6 +20,7 @@ const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addDownloadJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
addSearchEbookJob: vi.fn(() => Promise.resolve()),
}));
const downloadEbookMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
@@ -355,42 +356,75 @@ describe('Request action routes', () => {
expect(payload.error).toMatch(/Cannot fetch e-book/);
});
it('returns 400 when audiobook directory is missing', async () => {
it('creates ebook request and triggers search job', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
// Mock parent request lookup
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-6',
userId: 'user-1',
audiobookId: 'ab-1',
status: 'downloaded',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
});
// Mock check for existing ebook request
prismaMock.request.findFirst.mockResolvedValueOnce(null);
// Mock ebook request creation
prismaMock.request.create.mockResolvedValueOnce({
id: 'ebook-req-1',
type: 'ebook',
parentRequestId: 'req-6',
});
fsMock.access.mockRejectedValueOnce(new Error('missing'));
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toMatch(/directory not found/);
expect(payload.success).toBe(true);
expect(payload.message).toMatch(/created/i);
expect(payload.requestId).toBe('ebook-req-1');
expect(prismaMock.request.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'ebook',
parentRequestId: 'req-6',
status: 'pending',
}),
});
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
'ebook-req-1',
expect.objectContaining({
id: 'ab-1',
title: 'Title',
author: 'Author',
asin: 'ASIN123',
})
);
});
it('downloads ebook and returns success', async () => {
it('retries existing failed ebook request', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
configState.values.set('media_dir', '/media/audiobooks');
configState.values.set('audiobook_path_template', '{author}/{title} {asin}');
configState.values.set('ebook_sidecar_preferred_format', 'epub');
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
// Mock parent request lookup
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-7',
userId: 'user-1',
audiobookId: 'ab-1',
status: 'available',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
});
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' });
fsMock.access.mockResolvedValueOnce(undefined);
downloadEbookMock.mockResolvedValueOnce({
success: true,
format: 'epub',
filePath: '/media/audiobooks/Author/Title ASIN123/Title.epub',
// Mock existing failed ebook request
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'ebook-req-existing',
status: 'failed',
});
// Mock update for retry
prismaMock.request.update.mockResolvedValueOnce({
id: 'ebook-req-existing',
status: 'pending',
});
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
@@ -398,29 +432,35 @@ describe('Request action routes', () => {
const payload = await response.json();
expect(payload.success).toBe(true);
expect(downloadEbookMock).toHaveBeenCalledWith(
'ASIN123',
'Title',
'Author',
expect.stringContaining('Title ASIN123'),
'epub',
'https://ebooks.example',
undefined,
'http://flaresolverr'
);
expect(payload.message).toMatch(/retried/i);
expect(payload.requestId).toBe('ebook-req-existing');
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'ebook-req-existing' },
data: expect.objectContaining({
status: 'pending',
progress: 0,
errorMessage: null,
}),
});
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalled();
});
it('returns failure payload when ebook download fails', async () => {
it('returns message when ebook request already exists and in progress', async () => {
configState.values.set('ebook_sidecar_enabled', 'true');
// Mock parent request lookup
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-8',
userId: 'user-1',
audiobookId: 'ab-1',
status: 'downloaded',
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
});
fsMock.access.mockResolvedValueOnce(undefined);
downloadEbookMock.mockResolvedValueOnce({
success: false,
error: 'Download failed',
// Mock existing in-progress ebook request
prismaMock.request.findFirst.mockResolvedValueOnce({
id: 'ebook-req-existing',
status: 'downloading',
});
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
@@ -428,7 +468,11 @@ describe('Request action routes', () => {
const payload = await response.json();
expect(payload.success).toBe(false);
expect(payload.message).toMatch(/Download failed/);
expect(payload.message).toMatch(/already exists/i);
expect(payload.requestId).toBe('ebook-req-existing');
// Should not create new request or trigger search
expect(prismaMock.request.create).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,362 @@
/**
* Component: Direct Download Processor Tests
* Documentation: documentation/integrations/ebook-sidecar.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addOrganizeJob: vi.fn(() => Promise.resolve()),
addMonitorDirectDownloadJob: vi.fn(() => Promise.resolve()),
}));
const ebookScraperMock = vi.hoisted(() => ({
extractDownloadUrl: vi.fn(),
}));
const fsMock = vi.hoisted(() => ({
mkdir: vi.fn().mockResolvedValue(undefined),
stat: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
}));
const axiosMock = vi.hoisted(() => vi.fn());
const createWriteStreamMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
vi.mock('fs', () => ({
createWriteStream: createWriteStreamMock,
}));
vi.mock('axios', () => ({
default: axiosMock,
}));
describe('processStartDirectDownload', () => {
beforeEach(() => {
vi.clearAllMocks();
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'downloads_dir') return '/downloads';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_preferred_format') return 'epub';
return null;
});
});
it('updates request status to downloading', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.downloadHistory.findUnique.mockResolvedValue({
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
});
// Mock successful download
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
url: 'https://direct.example.com/book.epub',
format: 'epub',
});
// Mock axios stream
const mockWriteStream = {
on: vi.fn((event, cb) => {
if (event === 'finish') setTimeout(cb, 10);
return mockWriteStream;
}),
close: vi.fn(),
};
createWriteStreamMock.mockReturnValue(mockWriteStream);
const mockDataStream = {
on: vi.fn().mockReturnThis(),
pipe: vi.fn().mockReturnValue(mockWriteStream),
};
axiosMock.mockResolvedValue({
data: mockDataStream,
headers: { 'content-length': '1000000' },
});
fsMock.stat.mockResolvedValue({ size: 1000000 });
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
audiobookId: 'ab-1',
audiobook: { id: 'ab-1' },
});
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
const result = await processStartDirectDownload({
requestId: 'req-1',
downloadHistoryId: 'dh-1',
downloadUrl: 'https://slow.example.com/book',
targetFilename: 'Test Book.epub',
jobId: 'job-1',
});
// Check status updates
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-1' },
data: expect.objectContaining({
status: 'downloading',
progress: 0,
}),
});
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
where: { id: 'dh-1' },
data: expect.objectContaining({
downloadStatus: 'downloading',
}),
});
});
it('triggers organize job after successful download', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.downloadHistory.findUnique.mockResolvedValue({
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
});
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
url: 'https://direct.example.com/book.epub',
format: 'epub',
});
const mockWriteStream = {
on: vi.fn((event, cb) => {
if (event === 'finish') setTimeout(cb, 10);
return mockWriteStream;
}),
close: vi.fn(),
};
createWriteStreamMock.mockReturnValue(mockWriteStream);
const mockDataStream = {
on: vi.fn().mockReturnThis(),
pipe: vi.fn().mockReturnValue(mockWriteStream),
};
axiosMock.mockResolvedValue({
data: mockDataStream,
headers: { 'content-length': '500000' },
});
fsMock.stat.mockResolvedValue({ size: 500000 });
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-2',
audiobookId: 'ab-2',
audiobook: { id: 'ab-2' },
});
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
const result = await processStartDirectDownload({
requestId: 'req-2',
downloadHistoryId: 'dh-2',
downloadUrl: 'https://slow.example.com/book2',
targetFilename: 'Another Book.epub',
jobId: 'job-2',
});
expect(result.success).toBe(true);
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
'req-2',
'ab-2',
expect.stringContaining('Another Book.epub')
);
});
it('marks request as failed when all download attempts fail', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.downloadHistory.findUnique.mockResolvedValue({
torrentUrl: JSON.stringify([
'https://slow1.example.com/book',
'https://slow2.example.com/book',
]),
});
// All extract attempts fail
ebookScraperMock.extractDownloadUrl.mockResolvedValue(null);
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
const result = await processStartDirectDownload({
requestId: 'req-3',
downloadHistoryId: 'dh-3',
downloadUrl: 'https://slow1.example.com/book',
targetFilename: 'Failed Book.epub',
jobId: 'job-3',
});
expect(result.success).toBe(false);
// Verify the second call (final failure status update)
expect(prismaMock.request.update).toHaveBeenLastCalledWith({
where: { id: 'req-3' },
data: expect.objectContaining({
status: 'failed',
}),
});
expect(prismaMock.downloadHistory.update).toHaveBeenLastCalledWith({
where: { id: 'dh-3' },
data: expect.objectContaining({
downloadStatus: 'failed',
}),
});
});
it('uses FlareSolverr when configured', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.downloadHistory.findUnique.mockResolvedValue({
torrentUrl: JSON.stringify(['https://slow.example.com/book']),
});
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'downloads_dir') return '/downloads';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
return null;
});
ebookScraperMock.extractDownloadUrl.mockResolvedValue({
url: 'https://direct.example.com/book.epub',
format: 'epub',
});
const mockWriteStream = {
on: vi.fn((event, cb) => {
if (event === 'finish') setTimeout(cb, 10);
return mockWriteStream;
}),
close: vi.fn(),
};
createWriteStreamMock.mockReturnValue(mockWriteStream);
const mockDataStream = {
on: vi.fn().mockReturnThis(),
pipe: vi.fn().mockReturnValue(mockWriteStream),
};
axiosMock.mockResolvedValue({
data: mockDataStream,
headers: { 'content-length': '500000' },
});
fsMock.stat.mockResolvedValue({ size: 500000 });
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-4',
audiobookId: 'ab-4',
audiobook: { id: 'ab-4' },
});
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
await processStartDirectDownload({
requestId: 'req-4',
downloadHistoryId: 'dh-4',
downloadUrl: 'https://slow.example.com/book',
targetFilename: 'Flare Book.epub',
jobId: 'job-4',
});
expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith(
'https://slow.example.com/book',
'https://annas-archive.li',
'epub',
expect.anything(),
'http://flaresolverr:8191'
);
});
it('handles errors and updates request status', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
prismaMock.downloadHistory.findUnique.mockRejectedValue(new Error('Database error'));
const { processStartDirectDownload } = await import('@/lib/processors/direct-download.processor');
await expect(processStartDirectDownload({
requestId: 'req-5',
downloadHistoryId: 'dh-5',
downloadUrl: 'https://slow.example.com/book',
targetFilename: 'Error Book.epub',
jobId: 'job-5',
})).rejects.toThrow('Database error');
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-5' },
data: expect.objectContaining({
status: 'failed',
errorMessage: 'Database error',
}),
});
});
});
describe('processMonitorDirectDownload', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns completed status when download file exists', async () => {
fsMock.stat.mockResolvedValue({ size: 1000000 });
prismaMock.request.update.mockResolvedValue({});
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
const result = await processMonitorDirectDownload({
requestId: 'req-m1',
downloadHistoryId: 'dh-m1',
downloadId: 'dl_unknown',
targetPath: '/downloads/book.epub',
expectedSize: 1000000,
jobId: 'job-m1',
});
expect(result.success).toBe(true);
expect(result.completed).toBe(true);
});
it('returns not found when download is not tracked', async () => {
fsMock.stat.mockRejectedValue(new Error('ENOENT'));
const { processMonitorDirectDownload } = await import('@/lib/processors/direct-download.processor');
const result = await processMonitorDirectDownload({
requestId: 'req-m2',
downloadHistoryId: 'dh-m2',
downloadId: 'dl_missing',
targetPath: '/downloads/missing.epub',
expectedSize: 500000,
jobId: 'job-m2',
});
expect(result.success).toBe(false);
expect(result.message).toContain('not found');
});
});
@@ -40,6 +40,12 @@ vi.mock('@/lib/services/job-queue.service', () => ({
describe('processOrganizeFiles', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock for request lookup (processor needs to determine request type)
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-default',
type: 'audiobook', // Default to audiobook type
user: { plexUsername: 'testuser' },
});
});
it('organizes files and triggers filesystem scan when enabled', async () => {
@@ -0,0 +1,328 @@
/**
* Component: Search Ebook Processor Tests
* Documentation: documentation/integrations/ebook-sidecar.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addStartDirectDownloadJob: vi.fn(() => Promise.resolve()),
}));
const ebookScraperMock = vi.hoisted(() => ({
searchByAsin: vi.fn(),
searchByTitle: vi.fn(),
getSlowDownloadLinks: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
describe('processSearchEbook', () => {
beforeEach(() => {
vi.clearAllMocks();
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
return null;
});
});
it('searches by ASIN when available and triggers download', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
prismaMock.downloadHistory.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockResolvedValue('abc123md5');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
'https://slow1.example.com/abc123',
'https://slow2.example.com/abc123',
]);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
const result = await processSearchEbook({
requestId: 'req-1',
audiobook: {
id: 'ab-1',
title: 'Test Book',
author: 'Test Author',
asin: 'B001ASIN',
},
jobId: 'job-1',
});
expect(result.success).toBe(true);
expect(result.message).toContain('ASIN');
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
'B001ASIN',
'epub',
'https://annas-archive.li',
expect.anything(),
undefined
);
expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith(
'req-1',
'dh-1',
'https://slow1.example.com/abc123',
'Test Book - Test Author.epub',
undefined
);
});
it('falls back to title search when ASIN search fails', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
prismaMock.downloadHistory.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockResolvedValue(null);
ebookScraperMock.searchByTitle.mockResolvedValue('xyz789md5');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
'https://slow1.example.com/xyz789',
]);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
const result = await processSearchEbook({
requestId: 'req-2',
audiobook: {
id: 'ab-2',
title: 'Another Book',
author: 'Another Author',
asin: 'B002ASIN',
},
jobId: 'job-2',
});
expect(result.success).toBe(true);
expect(result.message).toContain('title search');
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
'Another Book',
'Another Author',
'epub',
'https://annas-archive.li',
expect.anything(),
undefined
);
});
it('searches by title when no ASIN is available', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-3' });
prismaMock.downloadHistory.update.mockResolvedValue({});
ebookScraperMock.searchByTitle.mockResolvedValue('noasin123');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
'https://slow.example.com/noasin123',
]);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
const result = await processSearchEbook({
requestId: 'req-3',
audiobook: {
id: 'ab-3',
title: 'No ASIN Book',
author: 'No ASIN Author',
// No asin field
},
jobId: 'job-3',
});
expect(result.success).toBe(true);
expect(ebookScraperMock.searchByAsin).not.toHaveBeenCalled();
expect(ebookScraperMock.searchByTitle).toHaveBeenCalled();
});
it('marks request as awaiting_search when no ebook found', async () => {
prismaMock.request.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockResolvedValue(null);
ebookScraperMock.searchByTitle.mockResolvedValue(null);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
const result = await processSearchEbook({
requestId: 'req-4',
audiobook: {
id: 'ab-4',
title: 'Unfindable Book',
author: 'Unknown Author',
asin: 'B004ASIN',
},
jobId: 'job-4',
});
expect(result.success).toBe(false);
expect(result.message).toContain('re-search');
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-4' },
data: expect.objectContaining({
status: 'awaiting_search',
errorMessage: expect.stringContaining('No ebook found'),
}),
});
expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled();
});
it('marks request as awaiting_search when no download links available', async () => {
prismaMock.request.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockResolvedValue('md5nolinks');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([]);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
const result = await processSearchEbook({
requestId: 'req-5',
audiobook: {
id: 'ab-5',
title: 'Book No Links',
author: 'Author No Links',
asin: 'B005ASIN',
},
jobId: 'job-5',
});
expect(result.success).toBe(false);
expect(result.message).toContain('re-search');
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-5' },
data: expect.objectContaining({
status: 'awaiting_search',
errorMessage: expect.stringContaining('no download links'),
}),
});
});
it('uses FlareSolverr when configured', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-6' });
prismaMock.downloadHistory.update.mockResolvedValue({});
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
return null;
});
ebookScraperMock.searchByAsin.mockResolvedValue('md5withflare');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue(['https://slow.example.com/flare']);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
await processSearchEbook({
requestId: 'req-6',
audiobook: {
id: 'ab-6',
title: 'Flare Book',
author: 'Flare Author',
asin: 'B006ASIN',
},
jobId: 'job-6',
});
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
'B006ASIN',
'epub',
'https://annas-archive.li',
expect.anything(),
'http://flaresolverr:8191'
);
});
it('fails request on unexpected errors', async () => {
prismaMock.request.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockRejectedValue(new Error('Network error'));
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
await expect(processSearchEbook({
requestId: 'req-7',
audiobook: {
id: 'ab-7',
title: 'Error Book',
author: 'Error Author',
asin: 'B007ASIN',
},
jobId: 'job-7',
})).rejects.toThrow('Network error');
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-7' },
data: expect.objectContaining({
status: 'failed',
errorMessage: 'Network error',
}),
});
});
it('creates download history with correct metadata', async () => {
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-8' });
prismaMock.downloadHistory.update.mockResolvedValue({});
ebookScraperMock.searchByAsin.mockResolvedValue('md5metadata');
ebookScraperMock.getSlowDownloadLinks.mockResolvedValue([
'https://link1.example.com',
'https://link2.example.com',
]);
const { processSearchEbook } = await import('@/lib/processors/search-ebook.processor');
await processSearchEbook({
requestId: 'req-8',
audiobook: {
id: 'ab-8',
title: 'Metadata Book',
author: 'Metadata Author',
asin: 'B008ASIN',
},
jobId: 'job-8',
});
expect(prismaMock.downloadHistory.create).toHaveBeenCalledWith({
data: expect.objectContaining({
requestId: 'req-8',
indexerName: "Anna's Archive",
torrentName: 'Metadata Book - Metadata Author.epub',
downloadClient: 'direct',
downloadStatus: 'queued',
selected: true,
qualityScore: 100, // ASIN match = 100
}),
});
// Check that all URLs are stored
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith({
where: { id: 'dh-8' },
data: {
torrentUrl: JSON.stringify([
'https://link1.example.com',
'https://link2.example.com',
]),
},
});
});
});
+14
View File
@@ -21,6 +21,10 @@ const processorsMock = vi.hoisted(() => ({
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
// Ebook processors
processSearchEbook: vi.fn().mockResolvedValue('ok'),
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
processMonitorDirectDownload: vi.fn().mockResolvedValue('ok'),
}));
const queueMock = vi.hoisted(() => ({
@@ -111,6 +115,16 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
}));
// Ebook processors
vi.mock('@/lib/processors/search-ebook.processor', () => ({
processSearchEbook: processorsMock.processSearchEbook,
}));
vi.mock('@/lib/processors/direct-download.processor', () => ({
processStartDirectDownload: processorsMock.processStartDirectDownload,
processMonitorDirectDownload: processorsMock.processMonitorDirectDownload,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
@@ -56,6 +56,9 @@ vi.mock('@/lib/utils/file-organizer', () => ({
describe('deleteRequest', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock for child request queries (audiobook requests check for child ebook requests)
prismaMock.request.findMany.mockResolvedValue([]);
prismaMock.request.updateMany.mockResolvedValue({ count: 0 });
});
it('returns not found when request is missing', async () => {
+8 -51
View File
@@ -275,17 +275,8 @@ describe('file organizer', () => {
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('downloads remote cover art and ebook sidecar when enabled', async () => {
it('downloads remote cover art when no local cover exists', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'true');
configState.values.set('ebook_sidecar_preferred_format', 'epub');
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
ebookMock.downloadEbook.mockResolvedValue({
success: true,
filePath: '/media/Author/Book/book.epub',
});
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
@@ -322,18 +313,11 @@ describe('file organizer', () => {
'https://images.example/cover.jpg',
expect.objectContaining({ responseType: 'arraybuffer' })
);
expect(ebookMock.downloadEbook).toHaveBeenCalledWith(
'ASIN123',
'Book',
'Author',
expectedDir,
'epub',
'https://ebooks.example',
undefined,
'http://flaresolverr'
);
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
// The file organizer no longer downloads ebooks inline
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(result.filesMovedCount).toBe(2);
expect(result.filesMovedCount).toBe(1);
});
it('records an error when cover art download fails', async () => {
@@ -444,36 +428,9 @@ describe('file organizer', () => {
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
});
it('records ebook sidecar errors when download throws', async () => {
configState.values.set('metadata_tagging_enabled', 'false');
configState.values.set('ebook_sidecar_enabled', 'true');
ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down'));
const organizer = new FileOrganizer('/media', '/tmp');
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
audioFiles: ['book.m4b'],
coverFile: undefined,
isFile: false,
});
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
fsMock.access.mockImplementation(async (filePath: string) => {
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
title: 'Book',
author: 'Author',
}, '{author}/{title}');
expect(result.success).toBe(true);
expect(result.errors).toContain('E-book sidecar failed');
});
// NOTE: The ebook sidecar test was removed because ebook downloads are now
// handled as first-class requests through the job queue, not inline during
// file organization. See organize-files.processor.ts createEbookRequestIfEnabled().
it('finds audio files and cover art in nested folders', async () => {
const organizer = new FileOrganizer('/media', '/tmp');
+153
View File
@@ -1034,6 +1034,159 @@ describe('ranking-algorithm', () => {
});
});
describe('Initial Variations (J.N. vs J N)', () => {
const algorithm = new RankingAlgorithm();
it('matches "J.N. Chaney" to torrent with "J N Chaney" in automatic mode', () => {
const torrent = {
...baseTorrent,
title: 'Infinite Crown by Terry Maggert, J N Chaney [ENG / M4B]',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Infinite Crown',
author: 'J.N. Chaney',
}, true); // requireAuthor: true (automatic mode)
// "J.N. Chaney" should normalize to "j n chaney"
// Torrent title should normalize to include "j n chaney"
// Author check should PASS
expect(breakdown.matchScore).toBeGreaterThan(0);
expect(breakdown.totalScore).toBeGreaterThanOrEqual(50);
});
it('matches author with periods to space-separated initials', () => {
const torrent = {
...baseTorrent,
title: 'Book Title by J K Rowling [M4B]',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Book Title',
author: 'J.K. Rowling',
}, true);
expect(breakdown.matchScore).toBeGreaterThan(0);
});
});
describe('CamelCase and Punctuation Separator Handling', () => {
const algorithm = new RankingAlgorithm();
it('matches CamelCase torrent title "VirginaEvans TheCorrespondent" to "The Correspondent" by "Virginia Evans"', () => {
const torrent = {
...baseTorrent,
title: 'VirginaEvans TheCorrespondent',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Correspondent',
author: 'Virginia Evans',
}, false); // requireAuthor: false - source has typo "Virgina" vs "Virginia"
// Should match after CamelCase normalization
// "VirginaEvans TheCorrespondent" → "virgina evans the correspondent"
// "The Correspondent" → "the correspondent" → required words: ["correspondent"]
// Coverage: "correspondent" found → passes
// Note: Author has typo in source data ("Virgina" vs "Virginia"), so fuzzy matching gives partial credit
expect(breakdown.matchScore).toBeGreaterThan(35);
});
it('matches period-separated title "Twelve.Months-Jim.Butcher" to "Twelve Months" by "Jim Butcher"', () => {
const torrent = {
...baseTorrent,
title: 'Twelve.Months-Jim.Butcher',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Twelve Months',
author: 'Jim Butcher',
});
// Should match after punctuation normalization
// "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
// Full title match + author match
expect(breakdown.matchScore).toBeGreaterThan(55);
});
it('matches mixed CamelCase and punctuation "AuthorName-BookTitle.2024"', () => {
const torrent = {
...baseTorrent,
title: 'JohnSmith-GreatBook.2024',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Great Book',
author: 'John Smith',
});
// "JohnSmith-GreatBook.2024" → "john smith great book 2024"
// Gets good fuzzy match score (title words present, author present)
expect(breakdown.matchScore).toBeGreaterThan(35);
});
it('matches CamelCase author with no separator "AuthorNameBookTitle"', () => {
const torrent = {
...baseTorrent,
title: 'BrandonSandersonMistborn',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Mistborn',
author: 'Brandon Sanderson',
});
// "BrandonSandersonMistborn" → "brandon sanderson mistborn"
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('handles underscore separators "Author_Name_Book_Title"', () => {
const torrent = {
...baseTorrent,
title: 'Jane_Doe_Amazing_Story',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Amazing Story',
author: 'Jane Doe',
});
// "Jane_Doe_Amazing_Story" → "jane doe amazing story"
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('preserves apostrophes in names like "O\'Brien"', () => {
const torrent = {
...baseTorrent,
title: "Tim O'Brien - The Things They Carried",
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'The Things They Carried',
author: "Tim O'Brien",
});
// Apostrophe should be preserved
expect(breakdown.matchScore).toBeGreaterThan(50);
});
it('handles real-world NZB title format with periods', () => {
const torrent = {
...baseTorrent,
title: 'William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs',
};
const breakdown = algorithm.getScoreBreakdown(torrent, {
title: 'Berlin Diary',
author: 'William L. Shirer',
});
// "William.L.Shirer-Berlin.Diary-AUDIOBOOK-96kbs" → "william l shirer berlin diary audiobook 96kbs"
// Gets partial score from fuzzy matching (title words + author words present)
expect(breakdown.matchScore).toBeGreaterThan(30);
});
});
describe('Legacy API Compatibility', () => {
it('supports legacy rankTorrents signature with separate parameters', () => {
const torrent = {