Files
bookorbit/server/src/modules/metadata/metadata.service.test.ts
T
neon 4d453c82ca fix(books): measure audiobook durations when Book Dock files them
Book Dock finalize, which also files book requests, left each audio file's
duration empty until the next library scan. The player derives progress only
from those, so a filed audiobook stayed at 0% and never reached Continue
Listening. Finalize now measures them once metadata is applied.

Refs #1757
2026-10-12 03:44:40 +00:00

2150 lines
80 KiB
TypeScript

vi.mock('fs/promises', () => ({
readFile: vi.fn(),
}));
vi.mock('./lib/cbz-metadata', () => ({
extractCbzMetadata: vi.fn(),
extractCbrMetadata: vi.fn(),
extractCb7Metadata: vi.fn(),
}));
vi.mock('./lib/epub', async (importOriginal) => ({
...(await importOriginal<typeof import('./lib/epub')>()),
extractEpubMetadata: vi.fn(),
}));
vi.mock('./lib/cover-epub', () => ({
extractEpubCover: vi.fn().mockImplementation(() => Promise.resolve(null)),
}));
vi.mock('./lib/cover-fb2', () => ({
extractFb2Cover: vi.fn().mockImplementation(() => Promise.resolve(null)),
}));
vi.mock('./lib/cover-cbz', () => ({
extractCbzCover: vi.fn().mockImplementation(() => Promise.resolve(null)),
}));
vi.mock('./lib/cover-cbr', () => ({
extractCbrCover: vi.fn().mockImplementation(() => Promise.resolve(null)),
}));
vi.mock('./lib/cover-cb7', () => ({
extractCb7Cover: vi.fn().mockImplementation(() => Promise.resolve(null)),
}));
vi.mock('./lib/filename-parser', () => ({
parseBookFilename: vi.fn(),
}));
vi.mock('./lib/fb2-parser', () => ({
parseFb2File: vi.fn(),
}));
vi.mock('./lib/mobi-parser', () => ({
parseMobiFile: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
extractMobiCover: (_path: string) => Promise.resolve(null),
}));
vi.mock('./lib/pdf-parser', () => ({
parsePdfFile: vi.fn(),
}));
vi.mock('./extractors/audio.extractor', () => ({
extractAudioMetadata: vi.fn().mockImplementation(() =>
Promise.resolve({
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
durationSeconds: null,
chapters: [],
coverBytes: null,
}),
),
parseAudioDuration: vi.fn().mockImplementation(() => Promise.resolve(null)),
probeAudioChapters: vi.fn().mockImplementation(() => Promise.resolve({ chapters: [], durationMs: null })),
}));
import sharp from 'sharp';
import { PgDialect } from 'drizzle-orm/pg-core';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
import { authors, bookAuthors, bookGenres, bookTags, genres, tags } from '../../db/schema';
import { extractCbzMetadata, type ParsedCbzMetadata } from './lib/cbz-metadata';
import { extractCbzCover } from './lib/cover-cbz';
import { extractEpubCover } from './lib/cover-epub';
import { extractEpubMetadata } from './lib/epub';
import { parseBookFilename } from './lib/filename-parser';
import { parseMobiFile } from './lib/mobi-parser';
import { parsePdfFile } from './lib/pdf-parser';
import { extractAudioMetadata, parseAudioDuration, probeAudioChapters } from './extractors/audio.extractor';
import { METADATA_AUTHORS_REPLACED } from './metadata-events.service';
import { MetadataService } from './metadata.service';
import { MetadataExtractionService } from './metadata-extraction.service';
const mockReadFile = readFile as MockedFunction<typeof readFile>;
const mockParseBookFilename = parseBookFilename as MockedFunction<typeof parseBookFilename>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
const mockExtractEpubCover = extractEpubCover as MockedFunction<typeof extractEpubCover>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
const mockExtractCbzCover = extractCbzCover as MockedFunction<typeof extractCbzCover>;
const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction<typeof extractAudioMetadata>;
const mockParseAudioDuration = parseAudioDuration as MockedFunction<typeof parseAudioDuration>;
const mockProbeAudioChapters = probeAudioChapters as MockedFunction<typeof probeAudioChapters>;
const makeDb = () => {
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const deleteWhere = vi.fn().mockResolvedValue(undefined);
const deleteBuilder = { where: deleteWhere };
const selectLimit = vi.fn().mockResolvedValue([]);
const selectWhereResult = {
limit: selectLimit,
then: (onFulfilled: (value: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([]).then(onFulfilled, onRejected),
};
const selectWhere = vi.fn().mockReturnValue(selectWhereResult);
const selectFrom = vi.fn().mockReturnValue({ where: selectWhere });
const insertReturning = vi.fn().mockResolvedValue([]);
const insertOnConflictDoNothing = vi.fn().mockResolvedValue(undefined);
const insertValues = vi.fn().mockReturnValue({
returning: insertReturning,
onConflictDoNothing: insertOnConflictDoNothing,
});
const db: any = {
update: vi.fn().mockReturnValue({ set: updateSet }),
delete: vi.fn().mockReturnValue(deleteBuilder),
select: vi.fn().mockReturnValue({ from: selectFrom }),
insert: vi.fn().mockReturnValue({ values: insertValues }),
execute: vi.fn().mockResolvedValue({ rowCount: 0, rows: [] }),
};
const transaction = vi.fn().mockImplementation(async (callback: (tx: typeof db) => Promise<unknown>) => callback(db));
db.transaction = transaction;
return {
db,
updateSet,
updateWhere,
deleteWhere,
selectLimit,
insertValues,
transaction,
};
};
describe('MetadataService', () => {
const embedder = { embedBook: vi.fn().mockResolvedValue(undefined) };
let defaultCoverStore: ReturnType<typeof makeCoverStore>;
function makeCoverStore() {
return {
mediaFor: vi.fn(),
chooseWriteMedium: vi.fn().mockResolvedValue('ebook'),
saveExtracted: vi.fn().mockResolvedValue(true),
chooseSidecarMedium: vi.fn().mockResolvedValue('ebook'),
hasActiveSlot: vi.fn().mockResolvedValue(false),
};
}
beforeEach(() => {
vi.resetAllMocks();
embedder.embedBook.mockResolvedValue(undefined);
defaultCoverStore = makeCoverStore();
mockReadFile.mockResolvedValue('');
mockParseBookFilename.mockReturnValue({ title: 'Fallback Title', publishedYear: 2001 });
mockParseMobiFile.mockResolvedValue(null);
mockParsePdfFile.mockResolvedValue(null);
mockExtractCbzMetadata.mockResolvedValue(null);
mockExtractCbzCover.mockResolvedValue(null);
mockExtractEpubMetadata.mockResolvedValue(null);
mockExtractEpubCover.mockResolvedValue(null);
mockExtractAudioMetadata.mockResolvedValue({
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
durationSeconds: null,
chapters: [],
coverBytes: null,
});
mockParseAudioDuration.mockResolvedValue(null);
mockProbeAudioChapters.mockResolvedValue({ chapters: [], durationMs: null });
});
function makeService(
db: unknown,
metadataEvents?: unknown,
overrides?: {
scoreService?: { calculateAndSave: ReturnType<typeof vi.fn> };
narratorService?: { replaceForBook: ReturnType<typeof vi.fn> };
comicMetadataRepository?: { upsert: ReturnType<typeof vi.fn> };
bookMetadataLockService?: {
isFieldLocked: ReturnType<typeof vi.fn>;
filterAutomatedBookUpdate?: ReturnType<typeof vi.fn>;
};
embedder?: { embedBook: ReturnType<typeof vi.fn> } | null;
seriesExpectedCount?: { record: ReturnType<typeof vi.fn> };
coverStore?: {
mediaFor?: ReturnType<typeof vi.fn>;
chooseWriteMedium?: ReturnType<typeof vi.fn>;
saveExtracted: ReturnType<typeof vi.fn>;
chooseSidecarMedium?: ReturnType<typeof vi.fn>;
hasActiveSlot?: ReturnType<typeof vi.fn>;
};
},
) {
return new MetadataService(
db as never,
new MetadataExtractionService(),
(overrides?.scoreService ?? { calculateAndSave: vi.fn().mockResolvedValue(undefined) }) as never,
(overrides?.narratorService ?? { replaceForBook: vi.fn().mockResolvedValue(undefined) }) as never,
(overrides?.comicMetadataRepository ?? { upsert: vi.fn().mockResolvedValue(undefined) }) as never,
(overrides?.bookMetadataLockService ?? {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
}) as never,
(overrides?.coverStore ?? defaultCoverStore) as never,
(overrides?.embedder ?? embedder) as never,
metadataEvents as never,
undefined,
undefined,
overrides?.seriesExpectedCount as never,
);
}
function stubEpubCoverExtraction(cover: Buffer): void {
mockExtractEpubMetadata.mockResolvedValueOnce({
title: 'Refreshable book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
coverBuffer: null,
});
mockExtractEpubCover.mockResolvedValueOnce(cover);
}
describe('downloadAndSaveCover', () => {
async function image(width: number, height: number): Promise<Buffer> {
return sharp({ create: { width, height, channels: 3, background: '#336699' } })
.jpeg()
.toBuffer();
}
function serve(images: Record<string, Buffer>) {
const fetchMock = vi.fn((url: URL | string) => {
const bytes = images[String(url)];
return Promise.resolve(bytes ? new Response(new Uint8Array(bytes)) : new Response(null, { status: 404 }));
});
global.fetch = fetchMock as never;
return fetchMock;
}
function makeStore(slotFilled: boolean) {
return {
hasActiveSlot: vi.fn().mockResolvedValue(slotFilled),
saveExtracted: vi.fn().mockResolvedValue(true),
};
}
it('moves past a thumbnail and a wrong-shape image to the first cover that fits the slot', async () => {
const coverStore = makeStore(true);
const service = makeService(makeDb().db, undefined, { coverStore });
const square = await image(600, 600);
const fetchMock = serve({
'https://img.example/thumb.jpg': await image(98, 98),
'https://img.example/portrait.jpg': await image(400, 600),
'https://img.example/square.jpg': square,
});
await expect(
service.downloadAndSaveCover(
[{ url: 'https://img.example/thumb.jpg' }, { url: 'https://img.example/portrait.jpg' }, { url: 'https://img.example/square.jpg' }],
9,
'audio',
),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(coverStore.saveExtracted).toHaveBeenCalledTimes(1);
expect(coverStore.saveExtracted).toHaveBeenCalledWith(9, 'audio', square, { origin: 'provider', overwrite: true });
});
it('never replaces a filled slot with a wrong-shape image', async () => {
const coverStore = makeStore(true);
const service = makeService(makeDb().db, undefined, { coverStore });
serve({ 'https://img.example/portrait.jpg': await image(400, 600) });
await expect(service.downloadAndSaveCover([{ url: 'https://img.example/portrait.jpg' }], 9, 'audio')).resolves.toBe(false);
expect(coverStore.saveExtracted).not.toHaveBeenCalled();
});
it('fills an empty slot with the first wrong-shape image when nothing fits', async () => {
const coverStore = makeStore(false);
const service = makeService(makeDb().db, undefined, { coverStore });
const firstPortrait = await image(400, 600);
serve({ 'https://img.example/a.jpg': firstPortrait, 'https://img.example/b.jpg': await image(410, 600) });
await expect(
service.downloadAndSaveCover(
[
{ url: 'https://img.example/a.jpg', fit: 'unknown' },
{ url: 'https://img.example/b.jpg', fit: 'mismatch' },
],
9,
'audio',
),
).resolves.toBe(true);
expect(coverStore.saveExtracted).toHaveBeenCalledWith(9, 'audio', firstPortrait, { origin: 'provider', overwrite: true });
});
it('does not download art the provider already called the wrong shape when the slot is filled', async () => {
const coverStore = makeStore(true);
const service = makeService(makeDb().db, undefined, { coverStore });
const fetchMock = serve({ 'https://img.example/portrait.jpg': await image(400, 600) });
await expect(service.downloadAndSaveCover([{ url: 'https://img.example/portrait.jpg', fit: 'mismatch' }], 9, 'audio')).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('stops after three downloads', async () => {
const coverStore = makeStore(true);
const service = makeService(makeDb().db, undefined, { coverStore });
const fetchMock = serve({});
await expect(
service.downloadAndSaveCover(
['a', 'b', 'c', 'd'].map((name) => ({ url: `https://img.example/${name}.jpg` })),
9,
'ebook',
),
).resolves.toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('saves a cover the user picked whatever its shape', async () => {
const coverStore = makeStore(true);
const service = makeService(makeDb().db, undefined, { coverStore });
const square = await image(600, 600);
serve({ 'https://img.example/square.jpg': square });
await expect(service.downloadAndSaveCover([{ url: 'https://img.example/square.jpg' }], 9, 'ebook', { userChosen: true })).resolves.toBe(true);
expect(coverStore.hasActiveSlot).not.toHaveBeenCalled();
expect(coverStore.saveExtracted).toHaveBeenCalledWith(9, 'ebook', square, { origin: 'provider', overwrite: true });
});
it('skips a locked slot without downloading', async () => {
const coverStore = makeStore(false);
const lockService = { isFieldLocked: vi.fn((_bookId: number, field: string) => Promise.resolve(field === 'audioCover')) };
const service = makeService(makeDb().db, undefined, { coverStore, bookMetadataLockService: lockService });
const fetchMock = serve({});
await expect(service.downloadAndSaveCover([{ url: 'https://img.example/square.jpg' }], 9, 'audio')).resolves.toBe(false);
expect(lockService.isFieldLocked).toHaveBeenCalledWith(9, 'audioCover');
expect(fetchMock).not.toHaveBeenCalled();
});
});
it('saveExtractedCoverBytes saves into the slot the image picks and rescores the book', async () => {
const scoreService = { calculateAndSave: vi.fn().mockResolvedValue(undefined) };
const service = makeService(makeDb().db, undefined, { scoreService });
defaultCoverStore.chooseWriteMedium.mockResolvedValue('audio');
const bytes = Buffer.from('image-bytes');
await service.saveExtractedCoverBytes(11, bytes);
expect(defaultCoverStore.chooseWriteMedium).toHaveBeenCalledWith(11, bytes);
expect(defaultCoverStore.saveExtracted).toHaveBeenCalledWith(11, 'audio', bytes, { origin: 'dock', overwrite: true });
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(11);
});
it('saveExtractedCoverBytes writes the requested slot and skips the rescore when the store declines', async () => {
const scoreService = { calculateAndSave: vi.fn().mockResolvedValue(undefined) };
const service = makeService(makeDb().db, undefined, { scoreService });
defaultCoverStore.saveExtracted.mockResolvedValue(false);
const bytes = Buffer.from('image-bytes');
await service.saveExtractedCoverBytes(12, bytes, 'ebook');
expect(defaultCoverStore.chooseWriteMedium).not.toHaveBeenCalled();
expect(defaultCoverStore.saveExtracted).toHaveBeenCalledWith(12, 'ebook', bytes, { origin: 'dock', overwrite: true });
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
});
it('refreshCoverForBook reports no refresh when the store declines the cover', async () => {
const scoreService = { calculateAndSave: vi.fn().mockResolvedValue(undefined) };
const service = makeService(makeDb().db, undefined, { scoreService });
defaultCoverStore.saveExtracted.mockResolvedValue(false);
stubEpubCoverExtraction(Buffer.from('image-bytes'));
await expect(service.refreshCoverForBook(14, '/book.epub', 'epub')).resolves.toBe(false);
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
});
it('downloadAndSaveCover no-ops on empty payloads and network failures', async () => {
const { db } = makeDb();
const service = makeService(db);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: () => Promise.resolve(Buffer.alloc(0)),
}) as never;
await expect(service.downloadAndSaveCover('https://img.example/empty.png', 4)).resolves.toBe(false);
expect(defaultCoverStore.saveExtracted).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
(global.fetch as vi.Mock).mockRejectedValue(new Error('timeout'));
await expect(service.downloadAndSaveCover('https://img.example/fail.png', 4)).resolves.toBe(false);
expect(defaultCoverStore.saveExtracted).not.toHaveBeenCalled();
});
it('downloadAndSaveCover skips when cover is locked or HTTP response is not ok', async () => {
const { db } = makeDb();
const lockService = {
isFieldLocked: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false),
filterAutomatedBookUpdate: vi.fn(),
};
const service = makeService(db, undefined, {
bookMetadataLockService: lockService,
});
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
arrayBuffer: () => Promise.resolve(Buffer.from('ignored')),
}) as never;
await expect(service.downloadAndSaveCover('https://img.example/locked.png', 4)).resolves.toBe(false);
await expect(service.downloadAndSaveCover('https://img.example/not-found.png', 4)).resolves.toBe(false);
expect(defaultCoverStore.saveExtracted).not.toHaveBeenCalled();
});
it('uses the audio lock for provider covers on audio-only books', async () => {
const { db } = makeDb();
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(true),
filterAutomatedBookUpdate: vi.fn(),
};
const coverStore = {
mediaFor: vi.fn().mockResolvedValue({ hasEbook: false, hasAudio: true }),
chooseWriteMedium: vi.fn(),
saveExtracted: vi.fn(),
};
const service = makeService(db, undefined, { bookMetadataLockService: lockService, coverStore });
global.fetch = vi.fn();
await expect(service.downloadAndSaveCover('https://img.example/audio.png', 4)).resolves.toBe(false);
expect(lockService.isFieldLocked).toHaveBeenCalledWith(4, 'audioCover');
expect(global.fetch).not.toHaveBeenCalled();
expect(coverStore.saveExtracted).not.toHaveBeenCalled();
});
it('uses the audio lock for explicit audio cover refreshes', async () => {
const { db } = makeDb();
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(true),
filterAutomatedBookUpdate: vi.fn(),
};
const coverStore = {
mediaFor: vi.fn(),
chooseWriteMedium: vi.fn(),
saveExtracted: vi.fn(),
};
const service = makeService(db, undefined, { bookMetadataLockService: lockService, coverStore });
await expect(service.refreshCoverForBook(4, '/book.epub', 'epub', 'audio')).resolves.toBe(false);
expect(lockService.isFieldLocked).toHaveBeenCalledWith(4, 'audioCover');
expect(coverStore.saveExtracted).not.toHaveBeenCalled();
});
it('extractAndSave short-circuits for unsupported formats and empty parser output', async () => {
const { db } = makeDb();
const service = makeService(db);
await expect(service.extractAndSave(1, '/tmp/book.unknown', 'unknown')).resolves.toBeUndefined();
mockParsePdfFile.mockResolvedValueOnce(null);
await expect(service.extractAndSave(2, '/tmp/book.pdf', 'pdf')).resolves.toBeUndefined();
expect(db.update).not.toHaveBeenCalled();
});
it('extractAndSaveIfAvailable(opf) persists standalone sidecar OPF metadata', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
const replaceAuthorsSpy = vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
mockReadFile.mockResolvedValue(`
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Sidecar Title</dc:title>
<dc:creator opf:role="aut">Sidecar Author</dc:creator>
<dc:identifier opf:scheme="GOOGLE">google-sidecar</dc:identifier>
</metadata>
</package>
`);
await expect(service.extractAndSaveIfAvailable(55, '/books/metadata.opf', 'opf')).resolves.toBe(true);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Sidecar Title',
googleBooksId: 'google-sidecar',
updatedAt: expect.any(Date),
}),
);
expect(replaceAuthorsSpy).toHaveBeenCalledWith(55, [{ name: 'Sidecar Author', sortName: null }]);
});
it('refreshCoverForBook returns false and avoids db writes when extractor reports no cover', async () => {
const { db } = makeDb();
const service = makeService(db);
// extractEpubCover is mocked to return null by default; parsePdfFile returns null → no cover
await expect(service.refreshCoverForBook(7, '/book.epub', 'epub')).resolves.toBe(false);
expect(db.update).not.toHaveBeenCalled();
});
it('refreshCoverForBook handles missing extractors, locked cover field, and successful refresh', async () => {
const { db } = makeDb();
const lockService = {
isFieldLocked: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
};
const service = makeService(db, undefined, {
bookMetadataLockService: lockService,
});
await expect(service.refreshCoverForBook(7, '/book.unknown', 'unknown')).resolves.toBe(false);
await expect(service.refreshCoverForBook(7, '/book.epub', 'epub')).resolves.toBe(false);
mockExtractEpubMetadata.mockResolvedValueOnce({
title: 'Refreshable book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
coverBuffer: null,
});
mockExtractEpubCover.mockResolvedValueOnce(Buffer.from('epub-cover-2'));
await expect(service.refreshCoverForBook(8, '/book2.epub', 'epub')).resolves.toBe(true);
expect(defaultCoverStore.saveExtracted).toHaveBeenCalledTimes(1);
expect(defaultCoverStore.saveExtracted).toHaveBeenCalledWith(8, 'ebook', Buffer.from('epub-cover-2'), { origin: 'embedded', overwrite: false });
});
it('extractAndSave propagates extractor errors', async () => {
const { db } = makeDb();
mockParsePdfFile.mockRejectedValue(new Error('bad metadata'));
const service = makeService(db);
await expect(service.extractAndSave(15, '/books/a.pdf', 'pdf')).rejects.toThrow('bad metadata');
});
it('extractAndSave propagates score calculation failures after persisting metadata', async () => {
const { db } = makeDb();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const scoreService = {
calculateAndSave: vi.fn().mockRejectedValue(new Error('score failed')),
};
const failingEmbedder = {
embedBook: vi.fn().mockRejectedValue(new Error('embed failed')),
};
const service = makeService(db, undefined, {
scoreService,
embedder: failingEmbedder,
});
mockParsePdfFile.mockResolvedValue({
title: 'Warn book',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
coverBuffer: null,
});
await expect(service.extractAndSave(44, '/tmp/warn-book.pdf', 'pdf')).rejects.toThrow('score failed');
await Promise.resolve();
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(44);
expect(failingEmbedder.embedBook).toHaveBeenCalledWith(44);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.extract_and_save] [fail]'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.embedding] [fail]'));
});
it('extractAndSave(pdf) surfaces parser warnings in logs', async () => {
const { db } = makeDb();
const service = makeService(db);
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
mockParsePdfFile.mockImplementation((_absolutePath, options) => {
options?.onWarning?.({
code: 'cover-extraction-failed',
absolutePath: '/tmp/warn.pdf',
errorClass: 'Error',
errorMessage: 'pdftoppm missing',
});
return Promise.resolve({
title: 'Warn PDF',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
ranobedbId: null,
itunesId: null,
coverBuffer: null,
});
});
await service.extractAndSave(21, '/tmp/warn.pdf', 'pdf');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[metadata.pdf_parse] [fail]'));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pdftoppm missing'));
});
it('extractAndSave(pdf) persists fallback title/year, page count, and extracted cover bytes', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
const replaceAuthorsSpy = vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
const replaceGenresSpy = vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
const replaceTagsSpy = vi.spyOn(service, 'replaceTags').mockResolvedValue(undefined);
mockParsePdfFile.mockResolvedValue({
title: null,
subtitle: 'Subtitle',
description: 'Description',
isbn10: '1234567890',
isbn13: '9781234567897',
publisher: ' Publisher\t Name ',
publishedYear: null,
language: 'en',
seriesName: 'Series Name',
seriesIndex: '2',
authors: [{ name: 'Author A', sortName: null }],
narrators: [],
genres: ['Fantasy'],
tags: ['Shelf'],
rating: 4.6,
pageCount: 321,
googleBooksId: 'google-1',
goodreadsId: 'goodreads-1',
amazonId: 'amazon-1',
hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'open-library-1',
ranobedbId: 'ranobe-1',
itunesId: 'itunes-1',
coverBuffer: Buffer.from('jpeg-bytes'),
});
mockParseBookFilename.mockReturnValue({ title: 'Title From Filename', publishedYear: 1999 });
await service.extractAndSave(22, '/tmp/book.pdf', 'pdf');
expect(mockParsePdfFile).toHaveBeenCalledWith(
'/tmp/book.pdf',
expect.objectContaining({
extractCover: true,
onWarning: expect.any(Function),
}),
);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Title From Filename',
publishedYear: 1999,
subtitle: 'Subtitle',
description: 'Description',
publisher: 'Publisher Name',
pageCount: 321,
seriesName: 'Series Name',
rating: 5,
googleBooksId: 'google-1',
goodreadsId: 'goodreads-1',
amazonId: 'amazon-1',
hardcoverId: 'hardcover-1',
hardcoverEditionId: '8941973',
openLibraryId: 'open-library-1',
ranobedbId: 'ranobe-1',
itunesId: 'itunes-1',
updatedAt: expect.any(Date),
}),
);
expect(defaultCoverStore.saveExtracted).toHaveBeenCalledWith(22, 'ebook', Buffer.from('jpeg-bytes'), { origin: 'embedded', overwrite: true });
expect(replaceAuthorsSpy).toHaveBeenCalledWith(22, [{ name: 'Author A', sortName: null }]);
expect(replaceGenresSpy).toHaveBeenCalledWith(22, ['Fantasy']);
expect(replaceTagsSpy).toHaveBeenCalledWith(22, ['Shelf']);
expect(embedder.embedBook).toHaveBeenCalledWith(22);
});
it('extractAndSave(cbz) persists comicvineId parsed from the embedded ComicInfo.xml', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockExtractCbzMetadata.mockResolvedValue({
title: 'Amazing Series',
subtitle: null,
seriesName: 'Amazing Series',
seriesIndex: '1',
seriesTotalBooks: null,
description: null,
publisher: null,
publishedDate: null,
publishedYear: null,
language: null,
pageCount: null,
rating: null,
isbn10: null,
isbn13: null,
authors: [],
genres: [],
tags: [],
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null,
ranobedbId: null,
koboId: null,
comicvineId: '140529',
lubimyczytacId: null,
aladinId: null,
itunesId: null,
comicMetadata: null,
});
await service.extractAndSave(23, '/tmp/book.cbz', 'cbz');
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
comicvineId: '140529',
}),
);
});
describe('extractAndSave(cbz) series length from ComicInfo Count', () => {
function comicMetadata(overrides: Partial<ParsedCbzMetadata> = {}): ParsedCbzMetadata {
return {
title: 'Amazing Series',
subtitle: null,
seriesName: 'Amazing Series',
seriesIndex: '1',
seriesTotalBooks: null,
description: null,
publisher: null,
publishedDate: null,
publishedYear: null,
language: null,
pageCount: null,
rating: null,
isbn10: null,
isbn13: null,
authors: [],
genres: [],
tags: [],
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
hardcoverEditionId: null,
openLibraryId: null,
ranobedbId: null,
koboId: null,
comicvineId: null,
lubimyczytacId: null,
aladinId: null,
itunesId: null,
comicMetadata: null,
...overrides,
};
}
async function scanWith(metadata: ParsedCbzMetadata) {
const { db } = makeDb();
const seriesExpectedCount = { record: vi.fn().mockResolvedValue(1) };
const service = makeService(db, undefined, { seriesExpectedCount });
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockExtractCbzMetadata.mockResolvedValue(metadata);
await service.extractAndSave(24, '/tmp/book.cbz', 'cbz');
return seriesExpectedCount;
}
it('records the series length the file declared', async () => {
const seriesExpectedCount = await scanWith(comicMetadata({ seriesTotalBooks: 12 }));
expect(seriesExpectedCount.record).toHaveBeenCalledWith('Amazing Series', 12);
});
it('passes the absent length through so no series is annotated', async () => {
const seriesExpectedCount = await scanWith(comicMetadata({ seriesTotalBooks: null }));
expect(seriesExpectedCount.record).toHaveBeenCalledWith('Amazing Series', null);
});
it('scans without error when the expected-count service is not provided', async () => {
const { db } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockExtractCbzMetadata.mockResolvedValue(comicMetadata({ seriesTotalBooks: 12 }));
await expect(service.extractAndSave(25, '/tmp/book.cbz', 'cbz')).resolves.toBeUndefined();
});
});
it('extractAndSave(mobi) ignores malformed publishedDate values from providers', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockParseMobiFile.mockResolvedValue({
title: 'Mobi Title',
description: null,
isbn: 'isbn',
publisher: null,
publishedDate: '20',
language: 'en',
authors: ['Author'],
tags: ['Tag'],
});
await service.extractAndSave(33, '/tmp/book.mobi', 'mobi');
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ publishedYear: null }));
});
it('extractAndSave(mobi) normalizes out-of-range 4-digit years to null before db writes', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockParseMobiFile.mockResolvedValue({
title: 'Ancient Book',
description: null,
isbn: 'isbn',
publisher: null,
publishedDate: '0101-01-01',
language: 'en',
authors: ['Author'],
tags: ['Tag'],
});
await service.extractAndSave(34, '/tmp/ancient-book.mobi', 'mobi');
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ publishedYear: null }));
});
it('extractAndSave(audio) persists expanded audiobook metadata through lock filtering', async () => {
const { db, updateSet } = makeDb();
const narratorService = {
replaceForBook: vi.fn().mockResolvedValue(undefined),
};
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
};
const service = makeService(db, undefined, {
narratorService,
bookMetadataLockService: lockService,
});
const replaceAuthorsSpy = vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
const replaceGenresSpy = vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
mockExtractAudioMetadata.mockResolvedValueOnce({
title: 'Audio Title',
subtitle: 'Audio Subtitle',
authors: [{ name: 'Audio Author', sortName: null }],
narrators: ['Audio Narrator'],
publisher: 'Audio Publisher',
publishedYear: 2024,
description: 'Audio Description',
language: 'eng',
seriesName: 'Audio\tSeries',
seriesIndex: '2',
genres: ['Fantasy', 'Adventure'],
audibleId: 'B0AUDIBLE',
librofmId: '9781234567890',
durationSeconds: 1234,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
coverBytes: null,
});
await service.extractAndSave(41, '/tmp/audio.m4b', 'm4b');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
41,
expect.objectContaining({
title: 'Audio Title',
subtitle: 'Audio Subtitle',
seriesName: 'Audio Series',
seriesIndex: '2',
genres: ['Fantasy', 'Adventure'],
audibleId: 'B0AUDIBLE',
librofmId: '9781234567890',
audioMetadata: expect.objectContaining({
narrators: ['Audio Narrator'],
durationSeconds: 1234,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
}),
}),
);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Audio Title',
subtitle: 'Audio Subtitle',
publisher: 'Audio Publisher',
publishedYear: 2024,
language: 'eng',
seriesName: 'Audio Series',
seriesIndex: '2',
audibleId: 'B0AUDIBLE',
librofmId: '9781234567890',
durationSeconds: 1234,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
updatedAt: expect.any(Date),
}),
);
expect(replaceAuthorsSpy).toHaveBeenCalledWith(41, [{ name: 'Audio Author', sortName: null }]);
expect(replaceGenresSpy).toHaveBeenCalledWith(41, ['Fantasy', 'Adventure']);
expect(narratorService.replaceForBook).toHaveBeenCalledWith(41, ['Audio Narrator']);
});
it('replaceAuthors normalizes names and deduplicates case-insensitively before db writes', async () => {
const { db, deleteWhere, transaction } = makeDb();
const service = makeService(db);
const insertedAuthors: Array<{ name: string; sortName: string | null }> = [];
const insertedBookAuthors: Array<{ bookId: number; authorId: number; displayOrder: number }> = [];
db.insert.mockImplementation((table: unknown) => {
if (table === authors) {
return {
values: (rows: Array<{ name: string; sortName: string | null }>) => {
insertedAuthors.push(...rows);
return {
onConflictDoNothing: () => ({
returning: () => Promise.resolve([{ id: 81, name: 'Alice Smith' }]),
}),
};
},
};
}
if (table === bookAuthors) {
return {
values: (rows: Array<{ bookId: number; authorId: number; displayOrder: number }>) => {
insertedBookAuthors.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
throw new Error('unexpected table in insert');
});
await service.replaceAuthors(5, [
{ name: ' Alice\t\n Smith ', sortName: ' Smith,\nAlice ' },
{ name: 'alice smith', sortName: 'ignored duplicate' },
{ name: ' ', sortName: null },
]);
expect(transaction).toHaveBeenCalledTimes(1);
expect(db.delete).toHaveBeenCalledWith(bookAuthors);
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(db.select).toHaveBeenCalledTimes(1);
expect(insertedAuthors).toEqual([{ name: 'Alice Smith', sortName: 'Smith, Alice' }]);
expect(insertedBookAuthors).toEqual([{ bookId: 5, authorId: 81, displayOrder: 0 }]);
});
it('replaceAuthors canonicalizes a legacy whitespace variant before linking it', async () => {
const { db, updateSet, updateWhere } = makeDb();
const service = makeService(db);
const insertedAuthors: Array<{ name: string; sortName: string | null }> = [];
const insertedBookAuthors: Array<{ bookId: number; authorId: number; displayOrder: number }> = [];
db.select.mockImplementation(() => ({
from: () => ({
where: () => Promise.resolve([{ id: 9, name: 'Known Author', normalizedName: 'known author' }]),
}),
}));
db.insert.mockImplementation((table: unknown) => {
if (table === authors) {
return {
values: (rows: Array<{ name: string; sortName: string | null }>) => {
insertedAuthors.push(...rows);
return {
onConflictDoNothing: () => ({
returning: () => Promise.resolve([]),
}),
};
},
};
}
if (table === bookAuthors) {
return {
values: (rows: Array<{ bookId: number; authorId: number; displayOrder: number }>) => {
insertedBookAuthors.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
throw new Error('unexpected table in insert');
});
await service.replaceAuthors(6, [{ name: 'Known Author', sortName: null }]);
expect(insertedAuthors).toEqual([]);
expect(db.update).toHaveBeenCalledWith(authors);
expect(updateSet).toHaveBeenCalledWith({ name: 'Known Author' });
expect(updateWhere).toHaveBeenCalledTimes(1);
expect(db.execute).toHaveBeenCalledTimes(2);
expect(insertedBookAuthors).toEqual([{ bookId: 6, authorId: 9, displayOrder: 0 }]);
});
it('replaceAuthors prefers an existing clean author row over a legacy whitespace variant', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
const insertedBookAuthors: Array<{ bookId: number; authorId: number; displayOrder: number }> = [];
db.select.mockImplementation(() => ({
from: () => ({
where: () =>
Promise.resolve([
{ id: 9, name: 'Known Author', normalizedName: 'known author' },
{ id: 10, name: 'Known Author', normalizedName: 'known author' },
]),
}),
}));
db.insert.mockImplementation((table: unknown) => {
if (table === authors) {
return {
values: () => ({
onConflictDoNothing: () => ({
returning: () => Promise.resolve([]),
}),
}),
};
}
if (table === bookAuthors) {
return {
values: (rows: Array<{ bookId: number; authorId: number; displayOrder: number }>) => {
insertedBookAuthors.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
throw new Error('unexpected table in insert');
});
await service.replaceAuthors(6, [{ name: 'Known Author', sortName: null }]);
expect(updateSet).not.toHaveBeenCalledWith({ name: 'Known Author' });
expect(insertedBookAuthors).toEqual([{ bookId: 6, authorId: 10, displayOrder: 0 }]);
});
it('replaceAuthors emits author replaced event with linked author ids', async () => {
const { db } = makeDb();
const metadataEvents = { emit: vi.fn() };
const service = makeService(db, metadataEvents);
db.select.mockImplementation(() => ({
from: () => ({
where: () => Promise.resolve([]),
}),
}));
db.insert.mockImplementation((table: unknown) => {
if (table === authors) {
return {
values: () => ({
onConflictDoNothing: () => ({
returning: () => Promise.resolve([{ id: 81, name: 'New Author' }]),
}),
}),
};
}
if (table === bookAuthors) {
return {
values: () => ({
onConflictDoNothing: () => Promise.resolve(undefined),
}),
};
}
throw new Error('unexpected table in insert');
});
await service.replaceAuthors(7, [{ name: 'New Author', sortName: null }]);
expect(metadataEvents.emit).toHaveBeenCalledWith(METADATA_AUTHORS_REPLACED, { bookId: 7, authorIds: [81] });
});
it('replaceGenres runs in transaction and normalizes unique names', async () => {
const { db, transaction } = makeDb();
const service = makeService(db);
const insertedGenres: string[] = [];
const insertedBookGenres: Array<{ bookId: number; genreId: number }> = [];
db.insert.mockImplementation((table: unknown) => {
if (table === genres) {
return {
values: (rows: Array<{ name: string }>) => {
insertedGenres.push(...rows.map((row) => row.name));
return {
onConflictDoNothing: () => ({
returning: () => Promise.resolve(rows.map((row, index) => ({ id: 77 + index, name: row.name }))),
}),
};
},
};
}
if (table === bookGenres) {
return {
values: (rows: Array<{ bookId: number; genreId: number }>) => {
insertedBookGenres.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
throw new Error('unexpected table in insert');
});
// The non-breaking space and the doubled space collapse onto the plain 'Fantasy' entry;
// stored un-collapsed they would each become a separate row that no search can reach.
await service.replaceGenres(12, [' Fantasy ', 'Fantasy', 'Fantasy\u00A0', 'Epic Fantasy', 'Epic\tFantasy', '', 'X'.repeat(250)]);
expect(transaction).toHaveBeenCalledTimes(1);
expect(db.delete).toHaveBeenCalledWith(bookGenres);
expect(insertedGenres).toEqual(['Fantasy', 'Epic Fantasy', 'X'.repeat(200)]);
expect(insertedBookGenres).toEqual([
{ bookId: 12, genreId: 77 },
{ bookId: 12, genreId: 78 },
{ bookId: 12, genreId: 79 },
]);
});
it('replaceTags runs in transaction and normalizes unique names', async () => {
const { db, transaction } = makeDb();
const service = makeService(db);
const insertedTags: string[] = [];
db.insert.mockImplementation((table: unknown) => {
if (table === tags) {
return {
values: (rows: Array<{ name: string }>) => {
insertedTags.push(...rows.map((row) => row.name));
return {
onConflictDoNothing: () => ({
returning: () => Promise.resolve(rows.map((row, index) => ({ id: 88 + index, name: row.name }))),
}),
};
},
};
}
if (table === bookTags) {
return {
values: () => ({
onConflictDoNothing: () => Promise.resolve(undefined),
}),
};
}
throw new Error('unexpected table in insert');
});
await service.replaceTags(19, [' Shelf ', 'Shelf', '', 'Y'.repeat(230)]);
expect(transaction).toHaveBeenCalledTimes(1);
expect(db.delete).toHaveBeenCalledWith(bookTags);
expect(insertedTags).toEqual(['Shelf', 'Y'.repeat(200)]);
});
describe('narrators from a sidecar OPF', () => {
const opfXml = (metadataBody: string) => `
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Sidecar Title</dc:title>
${metadataBody}
</metadata>
</package>`;
it('persists narrators declared by role nrt on a non-audio source', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const service = makeService(db, undefined, { narratorService });
mockReadFile.mockResolvedValue(
opfXml(`
<dc:creator opf:role="nrt">First Narrator</dc:creator>
<dc:contributor opf:role="nrt">Second Narrator</dc:contributor>
`),
);
await service.extractAndSave(31, '/books/Book/book.opf', 'opf');
expect(narratorService.replaceForBook).toHaveBeenCalledWith(31, ['First Narrator', 'Second Narrator']);
});
it('leaves narrators untouched when the OPF declares none', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const service = makeService(db, undefined, { narratorService });
mockReadFile.mockResolvedValue(opfXml(`<dc:publisher>Ace</dc:publisher>`));
await service.extractAndSave(32, '/books/Book/book.opf', 'opf');
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
});
it('skips narrators when the field is locked', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: Record<string, unknown>) => {
const rest = { ...dto };
delete rest.audioMetadata;
return Promise.resolve({ dto: rest, skippedFields: ['narrators'] });
}),
};
const service = makeService(db, undefined, { narratorService, bookMetadataLockService: lockService });
mockReadFile.mockResolvedValue(opfXml(`<dc:creator opf:role="nrt">First Narrator</dc:creator>`));
await service.extractAndSave(33, '/books/Book/book.opf', 'opf');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
33,
expect.objectContaining({ audioMetadata: { narrators: ['First Narrator'] } }),
);
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
});
});
it('extractAudioChaptersAndNarrators leaves narrators alone when the audio tags name none', async () => {
const { db, updateSet } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
};
const service = makeService(db, undefined, { narratorService, bookMetadataLockService: lockService });
mockExtractAudioMetadata.mockResolvedValueOnce({
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
librofmId: null,
durationSeconds: null,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
coverBytes: null,
} as never);
await service.extractAudioChaptersAndNarrators(73, '/tmp/audio.m4b', 'm4b');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
73,
expect.objectContaining({ audioMetadata: { chapters: [{ title: 'Chapter 1', startMs: 0 }] } }),
);
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ chapters: [{ title: 'Chapter 1', startMs: 0 }] }));
});
it('extractAudioChaptersAndNarrators updates filtered audio fields and supports early exits', async () => {
const { db, updateSet } = makeDb();
const narratorService = {
replaceForBook: vi.fn().mockResolvedValue(undefined),
};
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockResolvedValue({
dto: {
audibleId: 'B0SIDE',
librofmId: '9780987654321',
audioMetadata: {
chapters: [{ title: 'Chapter 1', startMs: 0 }],
narrators: ['Narrator A'],
},
},
skippedFields: [],
}),
};
const service = makeService(db, undefined, {
narratorService,
bookMetadataLockService: lockService,
});
mockExtractAudioMetadata.mockResolvedValueOnce({
title: null,
subtitle: null,
authors: [],
narrators: ['Narrator A'],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: 'B0SIDE',
librofmId: '9780987654321',
durationSeconds: null,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
coverBytes: null,
});
await service.extractAudioChaptersAndNarrators(70, '/tmp/audio.m4b', 'm4b');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
70,
expect.objectContaining({
audibleId: 'B0SIDE',
librofmId: '9780987654321',
audioMetadata: expect.objectContaining({
chapters: [{ title: 'Chapter 1', startMs: 0 }],
narrators: ['Narrator A'],
}),
}),
);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
audibleId: 'B0SIDE',
updatedAt: expect.any(Date),
}),
);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
librofmId: '9780987654321',
updatedAt: expect.any(Date),
}),
);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
chapters: [{ title: 'Chapter 1', startMs: 0 }],
updatedAt: expect.any(Date),
}),
);
expect(narratorService.replaceForBook).toHaveBeenCalledWith(70, ['Narrator A']);
await expect(service.extractAudioChaptersAndNarrators(71, '/tmp/audio.unknown', 'unknown')).resolves.toBeUndefined();
await expect(service.extractAudioChaptersAndNarrators(72, '/tmp/book.pdf', 'pdf')).resolves.toBeUndefined();
});
it('extractAudioChaptersAndNarrators keeps the track art for the audio slot without replacing a custom cover', async () => {
const { db } = makeDb();
const coverStore = {
mediaFor: vi.fn(),
chooseWriteMedium: vi.fn(),
saveExtracted: vi.fn().mockResolvedValue(true),
};
const service = makeService(db, undefined, { coverStore });
const art = Buffer.from('square-art');
mockExtractAudioMetadata.mockResolvedValueOnce({
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedDate: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
librofmId: null,
durationSeconds: null,
chapters: [],
coverBytes: art,
});
await service.extractAudioChaptersAndNarrators(74, '/tmp/audio.m4b', 'm4b');
expect(coverStore.saveExtracted).toHaveBeenCalledWith(74, 'audio', art, { origin: 'embedded', overwrite: false, skipIfUnchanged: true });
});
it('routes an OPF sidecar cover to the slot its shape picks, overwriting as the leading source', async () => {
const { db } = makeDb();
const coverStore = {
mediaFor: vi.fn(),
chooseWriteMedium: vi.fn(),
saveExtracted: vi.fn().mockResolvedValue(true),
chooseSidecarMedium: vi.fn().mockResolvedValue('audio'),
};
const service = makeService(db, undefined, { coverStore });
const bytes = Buffer.from('opf-cover');
await (service as unknown as { persistSourceCover: (id: number, format: string, cover: Buffer) => Promise<boolean> }).persistSourceCover(
55,
'opf',
bytes,
);
await (service as unknown as { persistSourceCover: (id: number, format: string, cover: Buffer) => Promise<boolean> }).persistSourceCover(
55,
'm4b',
bytes,
);
expect(coverStore.chooseSidecarMedium).toHaveBeenCalledWith(55, bytes);
expect(coverStore.saveExtracted).toHaveBeenNthCalledWith(1, 55, 'audio', bytes, { origin: 'opf', overwrite: true });
expect(coverStore.saveExtracted).toHaveBeenNthCalledWith(2, 55, 'audio', bytes, { origin: 'embedded', overwrite: true });
});
describe('extractMergedAudioChapters', () => {
const partOne = '/books/Red Rising/Red Rising - 01.m4b';
const partTwo = '/books/Red Rising/Red Rising - 02.m4b';
function makeLockService() {
return {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
};
}
function probeReturns(probes: Record<string, { chapters: { title: string; startMs: number }[]; durationMs: number | null }>) {
mockProbeAudioChapters.mockImplementation((absolutePath: string) =>
Promise.resolve(probes[absolutePath] ?? { chapters: [], durationMs: null }),
);
}
it('stores one chapter list covering every file, offset into the combined timeline', async () => {
const { db, updateSet } = makeDb();
const lockService = makeLockService();
const service = makeService(db, undefined, { bookMetadataLockService: lockService });
probeReturns({
[partOne]: {
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 120_000 },
],
durationMs: 360_000,
},
[partTwo]: { chapters: [{ title: 'Chapter 3', startMs: 0 }], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true });
expect(mockProbeAudioChapters).toHaveBeenCalledTimes(2);
expect(mockProbeAudioChapters).toHaveBeenNthCalledWith(1, partOne);
expect(mockProbeAudioChapters).toHaveBeenNthCalledWith(2, partTwo);
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 120_000 },
{ title: 'Chapter 3', startMs: 360_000 },
],
updatedAt: expect.any(Date),
}),
);
});
it('probes files in the order it is given, since offsets depend on playback order', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
probeReturns({
[partOne]: { chapters: [{ title: 'First', startMs: 0 }], durationMs: 100_000 },
[partTwo]: { chapters: [{ title: 'Second', startMs: 0 }], durationMs: 100_000 },
});
await service.extractMergedAudioChapters(42, [partTwo, partOne], { filesChanged: true });
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
chapters: [
{ title: 'Second', startMs: 0 },
{ title: 'First', startMs: 100_000 },
],
}),
);
});
it('does nothing for a single-file audiobook, which extraction already covers', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
await service.extractMergedAudioChapters(42, [partOne], { filesChanged: true });
expect(mockProbeAudioChapters).not.toHaveBeenCalled();
expect(updateSet).not.toHaveBeenCalled();
});
it('leaves stored chapters alone when a file length cannot be read', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
probeReturns({
[partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: null },
[partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true });
expect(updateSet).not.toHaveBeenCalled();
});
it('writes nothing when no file carries embedded chapters', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
probeReturns({
[partOne]: { chapters: [], durationMs: 360_000 },
[partTwo]: { chapters: [], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true });
expect(updateSet).not.toHaveBeenCalled();
});
it('passes the merged chapters through the metadata lock filter', async () => {
const { db, updateSet } = makeDb();
const lockService = makeLockService();
const service = makeService(db, undefined, { bookMetadataLockService: lockService });
probeReturns({
[partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: 360_000 },
[partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true });
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(42, {
audioMetadata: {
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 360_000 },
],
},
});
expect(updateSet).toHaveBeenCalled();
});
describe('with no file changed', () => {
function withStoredState(db: any, chapters: unknown[], fileRows: { absolutePath: string; durationSeconds: number | null }[]) {
let call = 0;
db.select.mockImplementation(() => ({
from: () => ({
where: () => {
call += 1;
if (call === 1) return { limit: () => Promise.resolve([{ chapters }]) };
return Promise.resolve(fileRows);
},
}),
}));
}
it('rebuilds a list that stops at the first file, repairing an older scan', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
withStoredState(
db,
[
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 240_000 },
],
[
{ absolutePath: partOne, durationSeconds: 360 },
{ absolutePath: partTwo, durationSeconds: 240 },
],
);
probeReturns({
[partOne]: {
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 240_000 },
],
durationMs: 360_000,
},
[partTwo]: { chapters: [{ title: 'Chapter 3', startMs: 0 }], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false });
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 240_000 },
{ title: 'Chapter 3', startMs: 360_000 },
],
}),
);
});
it('leaves an already merged list alone without probing the files again', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
withStoredState(
db,
[
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 3', startMs: 360_000 },
],
[
{ absolutePath: partOne, durationSeconds: 360 },
{ absolutePath: partTwo, durationSeconds: 240 },
],
);
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false });
expect(mockProbeAudioChapters).not.toHaveBeenCalled();
expect(updateSet).not.toHaveBeenCalled();
});
it('leaves a book with no stored chapters to the per-file fallback', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
withStoredState(
db,
[],
[
{ absolutePath: partOne, durationSeconds: 360 },
{ absolutePath: partTwo, durationSeconds: 240 },
],
);
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false });
expect(mockProbeAudioChapters).not.toHaveBeenCalled();
expect(updateSet).not.toHaveBeenCalled();
});
it('does not rebuild when a file length is unknown, since coverage cannot be judged', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() });
withStoredState(
db,
[{ title: 'Chapter 1', startMs: 0 }],
[
{ absolutePath: partOne, durationSeconds: null },
{ absolutePath: partTwo, durationSeconds: 240 },
],
);
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false });
expect(mockProbeAudioChapters).not.toHaveBeenCalled();
expect(updateSet).not.toHaveBeenCalled();
});
});
it('writes nothing when the lock filter drops the chapters', async () => {
const { db, updateSet } = makeDb();
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockResolvedValue({ dto: {}, skippedFields: ['chapters'] }),
};
const service = makeService(db, undefined, { bookMetadataLockService: lockService });
probeReturns({
[partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: 360_000 },
[partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 },
});
await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true });
expect(updateSet).not.toHaveBeenCalled();
});
});
describe('fixed-layout persistence', () => {
function epubOpf(renditionLayout: string | null) {
return {
title: 'Manga Vol. 1',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedDate: null,
publishedYear: null,
language: null,
pageCount: null,
rating: null,
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
customMetadata: {},
coverHref: null,
renditionLayout,
};
}
it('records a comic as fixed layout so Kobo sync can announce it as EPUB3FL', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
mockExtractEpubMetadata.mockResolvedValueOnce(epubOpf('pre-paginated') as never);
await service.extractAndSave(42, '/books/manga.epub', 'epub');
expect(updateSet).toHaveBeenCalledWith({ isFixedLayout: true });
});
it('records a reflowable novel explicitly, so it is never re-read on every sync', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
mockExtractEpubMetadata.mockResolvedValueOnce(epubOpf('reflowable') as never);
await service.extractAndSave(42, '/books/novel.epub', 'epub');
expect(updateSet).toHaveBeenCalledWith({ isFixedLayout: false });
});
it('records an EPUB that declares no layout as reflowable', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
mockExtractEpubMetadata.mockResolvedValueOnce(epubOpf(null) as never);
await service.extractAndSave(42, '/books/plain.epub', 'epub');
expect(updateSet).toHaveBeenCalledWith({ isFixedLayout: false });
});
it('writes nothing for a format whose extractor cannot report a layout', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
vi.spyOn(service, 'replaceAuthors').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceGenres').mockResolvedValue(undefined);
vi.spyOn(service, 'replaceTags').mockResolvedValue(undefined);
mockParsePdfFile.mockResolvedValueOnce({
title: 'Scan',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
genres: [],
tags: [],
rating: null,
pageCount: 10,
} as never);
await service.extractAndSave(42, '/books/scan.pdf', 'pdf');
expect(updateSet).not.toHaveBeenCalledWith(expect.objectContaining({ isFixedLayout: expect.anything() }));
});
});
it('extractAudioFileDuration writes duration only when parser returns a value', async () => {
const { db, updateSet } = makeDb();
const service = makeService(db);
mockParseAudioDuration.mockResolvedValueOnce(null).mockResolvedValueOnce(4321);
await service.extractAudioFileDuration(99, '/tmp/book.m4b');
await service.extractAudioFileDuration(99, '/tmp/book.m4b');
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 4321 });
});
it('extractAudioFileDuration returns the duration it wrote, or null when the file cannot be read', async () => {
const { db } = makeDb();
const service = makeService(db);
mockParseAudioDuration.mockResolvedValueOnce(null).mockResolvedValueOnce(4321);
await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBeNull();
await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBe(4321);
});
describe('measureMissingAudioDurations', () => {
function makeDurationDb(missing: Array<{ absolutePath: string }>) {
const selectWhere = vi.fn().mockResolvedValue(missing);
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const db = {
select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }),
update: vi.fn().mockReturnValue({ set: updateSet }),
};
return { db, selectWhere, updateSet, updateWhere };
}
it('looks only at the audio content files of the book that have no positive duration', async () => {
const { db, selectWhere } = makeDurationDb([]);
const service = makeService(db);
await service.measureMissingAudioDurations(42);
const query = new PgDialect().sqlToQuery(selectWhere.mock.calls[0]![0]);
expect(query.params).toEqual([42, 'content', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac', 0]);
expect(query.sql).toMatch(/"duration_seconds" is null or "book_files"\."duration_seconds" <= \$9/);
});
it('measures each file, then re-totals the book from the durations it wrote', async () => {
const { db, updateSet, updateWhere } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]);
const service = makeService(db);
mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : 900));
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 2, failed: 0 });
expect(mockParseAudioDuration.mock.calls.map((call) => call[0]).sort()).toEqual(['/books/a/01.mp3', '/books/a/02.mp3']);
expect(updateSet.mock.calls.map((call) => call[0]).sort((a, b) => a.durationSeconds - b.durationSeconds)).toEqual([
{ durationSeconds: 600 },
{ durationSeconds: 900 },
]);
expect(aggregate).toHaveBeenCalledExactlyOnceWith(42);
expect(aggregate.mock.invocationCallOrder[0]!).toBeGreaterThan(Math.max(...updateWhere.mock.invocationCallOrder));
});
it('leaves the total alone when a file cannot be measured, so the rest cannot shrink it', async () => {
const { db, updateSet } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]);
const service = makeService(db);
mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : null));
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 1, failed: 1 });
expect(updateSet).toHaveBeenCalledExactlyOnceWith({ durationSeconds: 600 });
expect(aggregate).not.toHaveBeenCalled();
});
it('treats a duration that rounds to zero as unmeasured', async () => {
const { db } = makeDurationDb([{ absolutePath: '/books/a/blip.m4b' }]);
const service = makeService(db);
mockParseAudioDuration.mockResolvedValue(0);
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 1 });
expect(aggregate).not.toHaveBeenCalled();
});
it('probes nothing and keeps the total for a book with no missing durations', async () => {
const { db } = makeDurationDb([]);
const service = makeService(db);
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 0 });
expect(mockParseAudioDuration).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
expect(aggregate).not.toHaveBeenCalled();
});
it('probes at most four files at a time', async () => {
const tracks = Array.from({ length: 10 }, (_, index) => ({ absolutePath: `/books/a/${String(index + 1).padStart(2, '0')}.mp3` }));
const { db } = makeDurationDb(tracks);
const service = makeService(db);
let inFlight = 0;
let peak = 0;
mockParseAudioDuration.mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight--;
return 60;
});
vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 10, failed: 0 });
expect(peak).toBe(4);
expect(mockParseAudioDuration).toHaveBeenCalledTimes(10);
});
});
it('extractAndAggregateAudioDuration writes the per-file duration before re-aggregating the total', async () => {
const { db } = makeDb();
const service = makeService(db);
const callOrder: string[] = [];
const extractSpy = vi.spyOn(service, 'extractAudioFileDuration').mockImplementation(() => {
callOrder.push('extract');
return Promise.resolve(null);
});
const aggregateSpy = vi.spyOn(service, 'aggregateAudioDuration').mockImplementation(() => {
callOrder.push('aggregate');
return Promise.resolve();
});
await service.extractAndAggregateAudioDuration(55, '/tmp/book.m4b');
expect(extractSpy).toHaveBeenCalledWith(55, '/tmp/book.m4b');
expect(aggregateSpy).toHaveBeenCalledWith(55);
expect(callOrder).toEqual(['extract', 'aggregate']);
});
it('replaceNarrators and upsertComicMetadata delegate to collaborators', async () => {
const { db } = makeDb();
const narratorService = {
replaceForBook: vi.fn().mockResolvedValue(undefined),
};
const comicMetadataRepository = {
upsert: vi.fn().mockResolvedValue(undefined),
};
const service = makeService(db, undefined, {
narratorService,
comicMetadataRepository,
});
await service.replaceNarrators(22, [{ name: 'N1', sortName: null }]);
await service.upsertComicMetadata(22, { issueNumber: 3 });
expect(narratorService.replaceForBook).toHaveBeenCalledWith(22, [{ name: 'N1', sortName: null }]);
expect(comicMetadataRepository.upsert).toHaveBeenCalledWith(22, { issueNumber: 3 });
});
it('replaceGenres and replaceTags use provided executors and resolve unresolved relation names', async () => {
const { db, transaction } = makeDb();
const service = makeService(db);
const genreDeleteWhere = vi.fn().mockResolvedValue(undefined);
const tagDeleteWhere = vi.fn().mockResolvedValue(undefined);
const bookGenreLinks: Array<{ bookId: number; genreId: number }> = [];
const bookTagLinks: Array<{ bookId: number; tagId: number }> = [];
const executor = {
delete: vi.fn((table: unknown) => {
if (table === bookGenres) return { where: genreDeleteWhere };
if (table === bookTags) return { where: tagDeleteWhere };
throw new Error('unexpected table in delete');
}),
insert: vi.fn((table: unknown) => {
if (table === genres) {
return {
values: () => ({
onConflictDoNothing: () => ({
returning: () => Promise.resolve([]),
}),
}),
};
}
if (table === tags) {
return {
values: () => ({
onConflictDoNothing: () => ({
returning: () => Promise.resolve([]),
}),
}),
};
}
if (table === bookGenres) {
return {
values: (rows: Array<{ bookId: number; genreId: number }>) => {
bookGenreLinks.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
if (table === bookTags) {
return {
values: (rows: Array<{ bookId: number; tagId: number }>) => {
bookTagLinks.push(...rows);
return { onConflictDoNothing: () => Promise.resolve(undefined) };
},
};
}
throw new Error('unexpected table in insert');
}),
select: vi.fn((fields: Record<string, unknown>) => {
const hasGenreName = Object.values(fields).includes(genres.name);
return {
from: () => ({
where: () => (hasGenreName ? Promise.resolve([{ id: 501, name: 'Mystery' }]) : Promise.resolve([{ id: 601, name: 'Shelf' }])),
}),
};
}),
};
await service.replaceGenres(41, ['Mystery'], { executor: executor as never });
await service.replaceTags(41, ['Shelf'], { executor: executor as never });
expect(transaction).not.toHaveBeenCalled();
expect(genreDeleteWhere).toHaveBeenCalledTimes(1);
expect(tagDeleteWhere).toHaveBeenCalledTimes(1);
expect(bookGenreLinks).toEqual([{ bookId: 41, genreId: 501 }]);
expect(bookTagLinks).toEqual([{ bookId: 41, tagId: 601 }]);
});
it('aggregateAudioDuration sums only files that match the selected primary audio format', async () => {
const primaryWhere = vi.fn().mockResolvedValue([{ format: 'm4b' }]);
const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere });
const primaryFrom = vi.fn().mockReturnValue({ innerJoin: primaryInnerJoin });
const aggregateWhere = vi.fn().mockResolvedValue([{ total: 3600 }]);
const aggregateFrom = vi.fn().mockReturnValue({ where: aggregateWhere });
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const db = {
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }).mockReturnValueOnce({ from: aggregateFrom }),
update: vi.fn().mockReturnValue({ set: updateSet }),
};
const service = makeService(db);
await service.aggregateAudioDuration(42);
expect(primaryWhere).toHaveBeenCalledTimes(1);
expect(aggregateWhere).toHaveBeenCalledTimes(1);
expect(new PgDialect().sqlToQuery(aggregateWhere.mock.calls[0]![0]).params).toEqual([42, 'content', 'm4b']);
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 3600 });
});
it('aggregateAudioDuration sums attached audio when an EPUB is primary', async () => {
const primaryWhere = vi.fn().mockResolvedValue([{ format: 'epub' }]);
const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere });
const primaryFrom = vi.fn().mockReturnValue({ innerJoin: primaryInnerJoin });
const aggregateWhere = vi.fn().mockResolvedValue([{ total: 3600 }]);
const aggregateFrom = vi.fn().mockReturnValue({ where: aggregateWhere });
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const db = {
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }).mockReturnValueOnce({ from: aggregateFrom }),
update: vi.fn().mockReturnValue({ set: updateSet }),
};
const service = makeService(db);
await service.aggregateAudioDuration(99);
expect(new PgDialect().sqlToQuery(aggregateWhere.mock.calls[0]![0]).params).toEqual([99, 'content', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']);
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 3600 });
});
it('aggregateAudioDuration no-ops without a selected primary file', async () => {
const primaryWhere = vi.fn().mockResolvedValue([]);
const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere });
const primaryFrom = vi.fn().mockReturnValue({ innerJoin: primaryInnerJoin });
const db = {
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }),
update: vi.fn(),
};
const service = makeService(db);
await service.aggregateAudioDuration(99);
expect(db.update).not.toHaveBeenCalled();
});
});