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()), 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; const mockParseBookFilename = parseBookFilename as MockedFunction; const mockParseMobiFile = parseMobiFile as MockedFunction; const mockParsePdfFile = parsePdfFile as MockedFunction; const mockExtractEpubCover = extractEpubCover as MockedFunction; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; const mockExtractCbzCover = extractCbzCover as MockedFunction; const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction; const mockParseAudioDuration = parseAudioDuration as MockedFunction; const mockProbeAudioChapters = probeAudioChapters as MockedFunction; 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) => 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; 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 }; narratorService?: { replaceForBook: ReturnType }; comicMetadataRepository?: { upsert: ReturnType }; bookMetadataLockService?: { isFieldLocked: ReturnType; filterAutomatedBookUpdate?: ReturnType; }; embedder?: { embedBook: ReturnType } | null; seriesExpectedCount?: { record: ReturnType }; coverStore?: { mediaFor?: ReturnType; chooseWriteMedium?: ReturnType; saveExtracted: ReturnType; chooseSidecarMedium?: ReturnType; hasActiveSlot?: ReturnType; }; }, ) { 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 { return sharp({ create: { width, height, channels: 3, background: '#336699' } }) .jpeg() .toBuffer(); } function serve(images: Record) { 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(` Sidecar Title Sidecar Author google-sidecar `); 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 { 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) => ` Sidecar Title ${metadataBody} `; 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(` First Narrator Second Narrator `), ); 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(`Ace`)); 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) => { const rest = { ...dto }; delete rest.audioMetadata; return Promise.resolve({ dto: rest, skippedFields: ['narrators'] }); }), }; const service = makeService(db, undefined, { narratorService, bookMetadataLockService: lockService }); mockReadFile.mockResolvedValue(opfXml(`First Narrator`)); 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 }).persistSourceCover( 55, 'opf', bytes, ); await (service as unknown as { persistSourceCover: (id: number, format: string, cover: Buffer) => Promise }).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) { 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) => { 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(); }); });