mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
5d8ac2f73d
Introduce centralized language configuration and wire locale-aware behavior across scraping and ranking. Adds src/lib/constants/language-config.ts with per-language scraping rules, stop words, and character replacements; replaces AudibleRegion.isEnglish with a language field in types and AUDIBLE_REGIONS. Update AudibleService, ebook scraper, processors, and API routes to use getLanguageForRegion so Anna's Archive searches, scraping selectors, runtime/rating parsing, and ranking use language-specific params and filters. Extend ranking algorithm to accept stopWords and characterReplacements and apply them during normalization and matching. Update UI selects to mark non-English regions and adjust tests accordingly.
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
/**
|
|
* 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(),
|
|
getAudibleRegion: vi.fn().mockResolvedValue('us'),
|
|
}));
|
|
|
|
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.getAudibleRegion.mockResolvedValue('us');
|
|
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_annas_archive_enabled') return 'true';
|
|
if (key === 'ebook_indexer_search_enabled') return 'false';
|
|
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("Anna's Archive");
|
|
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
|
|
'B001ASIN',
|
|
'epub',
|
|
'https://annas-archive.li',
|
|
expect.anything(),
|
|
undefined,
|
|
'en'
|
|
);
|
|
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("Anna's Archive");
|
|
expect(ebookScraperMock.searchByAsin).toHaveBeenCalled();
|
|
expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith(
|
|
'Another Book',
|
|
'Another Author',
|
|
'epub',
|
|
'https://annas-archive.li',
|
|
expect.anything(),
|
|
undefined,
|
|
'en'
|
|
);
|
|
});
|
|
|
|
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'),
|
|
lastSearchAt: expect.any(Date),
|
|
}),
|
|
});
|
|
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 ebook found'),
|
|
lastSearchAt: expect.any(Date),
|
|
}),
|
|
});
|
|
});
|
|
|
|
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';
|
|
if (key === 'ebook_annas_archive_enabled') return 'true';
|
|
if (key === 'ebook_indexer_search_enabled') return 'false';
|
|
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',
|
|
'en'
|
|
);
|
|
});
|
|
|
|
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',
|
|
]),
|
|
},
|
|
});
|
|
});
|
|
});
|