Files
bookorbit/server/src/modules/upload/upload-processor.service.test.ts
T
Dennis 428aefe8d1 feat(scanner): regex metadata extraction (#1669)
feat(metadata): extract metadata from file paths with regex rules (#1669)

Let libraries define ordered regex rules that read title, authors, series,
series number and dates from each book's path relative to its library folder.
Run rules in a bounded worker pool, honour source precedence and field locks,
and apply them on scans, uploads and watcher moves. Also retry unprobed M4B
tag writes with the mp4 muxer.

Closes #1351

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-10-16 16:24:11 -06:00

805 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
vi.mock('fs/promises', () => ({ stat: vi.fn() }));
vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() }));
vi.mock('../../db/audio-track-order', () => ({ realignAudioTrackOrder: vi.fn() }));
import { InternalServerErrorException } from '@nestjs/common';
import { PgDialect } from 'drizzle-orm/pg-core';
import type { SQL } from 'drizzle-orm';
import { stat } from 'fs/promises';
import { computeFileHash } from '../scanner/lib/hash';
import { realignAudioTrackOrder } from '../../db/audio-track-order';
import { books, bookFiles, bookMetadata, uploadSessions } from '../../db/schema';
import { UploadProcessorService } from './upload-processor.service';
const mockStat = stat as MockedFunction<typeof stat>;
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction<typeof realignAudioTrackOrder>;
describe('UploadProcessorService', () => {
const metadataService = {
extractAndSave: vi.fn(),
extractAndAggregateAudioDuration: vi.fn(),
extractAudioChaptersAndNarrators: vi.fn(),
extractMergedAudioChapters: vi.fn(),
};
const orchestrator = {
scheduleImportedBooksIfEligible: vi.fn(),
};
const coverStore = {
removeCoverDirectory: vi.fn(),
};
const insertBooksReturning = vi.fn();
const insertBooksValues = vi.fn();
const insertBookMetadataValues = vi.fn();
const insertBookFilesReturning = vi.fn();
const insertBookFilesValues = vi.fn();
const insertBookFilesOnConflict = vi.fn();
const updateBooksSet = vi.fn();
const updateBooksWhere = vi.fn();
const updateSessionSet = vi.fn();
const updateSessionWhere = vi.fn();
const selectFrom = vi.fn();
const selectInnerJoin = vi.fn();
const selectWhere = vi.fn();
const selectFor = vi.fn();
const selectLimit = vi.fn();
const selectOrderBy = vi.fn();
const deleteWhere = vi.fn();
const tx = {
execute: vi.fn().mockResolvedValue(undefined),
select: vi.fn(() => ({ from: selectFrom })),
insert: vi.fn((table: unknown) => {
if (table === books) {
return { values: insertBooksValues };
}
if (table === bookMetadata) {
return { values: insertBookMetadataValues };
}
if (table === bookFiles) {
return { values: insertBookFilesValues };
}
throw new Error('unexpected table');
}),
update: vi.fn((table: unknown) => {
if (table === books) return { set: updateBooksSet };
if (table === uploadSessions) return { set: updateSessionSet };
throw new Error('unexpected table');
}),
delete: vi.fn((table: unknown) => {
if (table === books || table === bookFiles) return { where: deleteWhere };
throw new Error('unexpected table');
}),
};
const db = {
select: vi.fn(),
transaction: vi.fn(async (callback: (innerTx: typeof tx) => Promise<unknown>) => callback(tx)),
};
const audioFilesWhere = vi.fn();
let service: UploadProcessorService;
beforeEach(() => {
vi.resetAllMocks();
db.select.mockReturnValue({ from: () => ({ where: audioFilesWhere }) });
audioFilesWhere.mockResolvedValue([{ absolutePath: '/path/to/library/book.m4b', format: 'm4b' }]);
selectFrom.mockReturnValue({ where: selectWhere, innerJoin: selectInnerJoin });
selectInnerJoin.mockReturnValue({ where: selectWhere });
selectWhere.mockReturnValue({ limit: selectLimit, for: selectFor, orderBy: selectOrderBy });
selectFor.mockReturnValue({ limit: selectLimit });
selectLimit.mockResolvedValue([]); // no existing book by default
selectOrderBy.mockResolvedValue([]); // no content files to rank by default
insertBooksValues.mockReturnValue({ returning: insertBooksReturning });
insertBooksReturning.mockResolvedValue([{ id: 42 }]);
insertBookMetadataValues.mockResolvedValue(undefined);
insertBookFilesOnConflict.mockReturnValue({ returning: insertBookFilesReturning });
insertBookFilesValues.mockReturnValue({ returning: insertBookFilesReturning, onConflictDoUpdate: insertBookFilesOnConflict });
insertBookFilesReturning.mockResolvedValue([{ id: 420 }]);
updateBooksSet.mockReturnValue({ where: updateBooksWhere });
updateBooksWhere.mockResolvedValue(undefined);
deleteWhere.mockResolvedValue(undefined);
updateSessionSet.mockReturnValue({ where: updateSessionWhere });
updateSessionWhere.mockResolvedValue(undefined);
orchestrator.scheduleImportedBooksIfEligible.mockResolvedValue(0);
coverStore.removeCoverDirectory.mockResolvedValue(undefined);
mockStat.mockResolvedValue({ ino: 111n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
mockComputeFileHash.mockResolvedValue('hash-abc');
service = new UploadProcessorService(db as any, metadataService as any, coverStore as any, orchestrator as any);
});
it('creates book, metadata, and content file rows with fingerprint/stat data in a transaction', async () => {
const result = await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
expect(result).toEqual({ bookId: 42, created: true });
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(insertBooksValues).toHaveBeenCalledWith({ libraryId: 1, libraryFolderId: 2, folderPath: '/folder', status: 'present' });
expect(insertBookMetadataValues).toHaveBeenCalledWith({ bookId: 42 });
expect(insertBookFilesValues).toHaveBeenCalledWith(
expect.objectContaining({
bookId: 42,
libraryFolderId: 2,
absolutePath: '/folder/book.epub',
relPath: 'book/book.epub',
ino: 111n,
sizeBytes: 12345,
fileHash: 'hash-abc',
format: 'epub',
role: 'content',
}),
);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 420 });
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
});
it('persists the session result in the same transaction as the book record', async () => {
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345, { uploadSessionId: 'session-id' });
expect(tx.update).toHaveBeenCalledWith(uploadSessions);
expect(updateSessionSet).toHaveBeenCalledWith(expect.objectContaining({ resultBookId: 42 }));
expect(updateSessionWhere).toHaveBeenCalled();
});
it('adds a file to an existing book when the folder path already exists in the library', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99 }]);
const result = await service.createBookRecord(1, 2, '/folder', '/folder/book2.epub', 'book/book2.epub', 'epub', 5000);
expect(result).toEqual({ bookId: 99, created: false });
expect(insertBooksValues).not.toHaveBeenCalled();
expect(insertBookMetadataValues).not.toHaveBeenCalled();
expect(updateBooksSet).not.toHaveBeenCalled();
expect(insertBookFilesValues).toHaveBeenCalledWith(
expect.objectContaining({
bookId: 99,
libraryFolderId: 2,
absolutePath: '/folder/book2.epub',
relPath: 'book/book2.epub',
format: 'epub',
role: 'content',
}),
);
expect(insertBookFilesOnConflict).toHaveBeenCalledWith(
expect.objectContaining({
target: bookFiles.absolutePath,
set: expect.objectContaining({ bookId: 99, libraryFolderId: 2, format: 'epub' }),
}),
);
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
});
it('renumbers the tracks of the book an audio file joins, inside the same transaction', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]);
await service.createBookRecord(1, 2, '/folder', '/folder/Part 03.mp3', 'book/Part 03.mp3', 'mp3', 5000);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 99);
expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(insertBookFilesValues.mock.invocationCallOrder[0]!);
});
it('leaves track order alone for an audio file that starts a book, or a file that is not audio', async () => {
await service.createBookRecord(1, 2, '/folder', '/folder/Part 01.mp3', 'book/Part 01.mp3', 'mp3', 5000);
selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 5000);
expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled();
});
it('preserves oversized MergerFS inode values before persisting', async () => {
mockStat.mockResolvedValueOnce({ ino: 14351917807348929000n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
expect(insertBookFilesValues).toHaveBeenCalledWith(
expect.objectContaining({
ino: 14351917807348929000n,
}),
);
});
it('preserves oversized MergerFS inode values in existing-book upserts', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99 }]);
mockStat.mockResolvedValueOnce({ ino: 14351917807348929000n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
await service.createBookRecord(1, 2, '/folder', '/folder/book2.epub', 'book/book2.epub', 'epub', 5000);
expect(insertBookFilesOnConflict).toHaveBeenCalledWith(
expect.objectContaining({
set: expect.objectContaining({
ino: 14351917807348929000n,
}),
}),
);
});
it('preserves precision-unsafe inodes exactly before persisting', async () => {
mockStat.mockResolvedValueOnce({ ino: 651896050678335552n, mtime: new Date('2024-01-01') } as Awaited<ReturnType<typeof stat>>);
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
expect(insertBookFilesValues).toHaveBeenCalledWith(
expect.objectContaining({
ino: 651896050678335552n,
}),
);
});
it('refreshes a stale book_files record when the absolute path already exists in the db', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99 }]);
insertBookFilesOnConflict.mockReturnValueOnce({ returning: vi.fn().mockResolvedValue([{ id: 777 }]) });
const result = await service.createBookRecord(1, 2, '/folder', '/folder/stale.epub', 'book/stale.epub', 'epub', 1000);
expect(result).toEqual({ bookId: 99, created: false });
expect(insertBookFilesOnConflict).toHaveBeenCalled();
});
it('throws InternalServerErrorException when creating the book row fails', async () => {
insertBooksReturning.mockResolvedValueOnce([]);
await expect(service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345)).rejects.toBeInstanceOf(
InternalServerErrorException,
);
});
it('throws InternalServerErrorException when creating the book file row fails', async () => {
insertBookFilesReturning.mockResolvedValueOnce([]);
await expect(service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345)).rejects.toBeInstanceOf(
InternalServerErrorException,
);
});
it('throws InternalServerErrorException when adding a file to an existing book fails', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99 }]);
insertBookFilesReturning.mockResolvedValueOnce([]);
await expect(service.createBookRecord(1, 2, '/folder', '/folder/book2.epub', 'book/book2.epub', 'epub', 5000)).rejects.toBeInstanceOf(
InternalServerErrorException,
);
});
it('extracts local metadata before queueing an import fetch', async () => {
const order: string[] = [];
metadataService.extractAndSave.mockImplementation(() => {
order.push('extract');
return Promise.resolve();
});
orchestrator.scheduleImportedBooksIfEligible.mockImplementation(() => {
order.push('schedule');
return Promise.resolve(1);
});
await (service as any).runNewBookImport(42, 1, '/folder/book.cbz', 'cbz');
expect(order).toEqual(['extract', 'schedule']);
expect(orchestrator.scheduleImportedBooksIfEligible).toHaveBeenCalledWith(1, [42]);
});
it('logs but suppresses post-extraction scheduling failures', async () => {
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
orchestrator.scheduleImportedBooksIfEligible.mockRejectedValue(new Error('queue offline'));
await (service as any).runNewBookImport(42, 1, '/folder/book.epub', 'epub');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('queue offline'));
});
it('extractMetadataAsync ignores unsupported formats', () => {
service.extractMetadataAsync(1, '/tmp/file.txt', 'txt');
expect(metadataService.extractAndSave).not.toHaveBeenCalled();
});
it('extractMetadataAsync logs and suppresses extraction errors', async () => {
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
metadataService.extractAndSave.mockRejectedValue(new Error('upstream failed'));
service.extractMetadataAsync(9, '/tmp/a.epub', 'epub');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndSave).toHaveBeenCalledWith(9, '/tmp/a.epub', 'epub');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('upstream failed'));
});
it('extractMetadataAsync extracts per-file duration after saving metadata for audio formats', async () => {
metadataService.extractAndSave.mockResolvedValue(undefined);
metadataService.extractAndAggregateAudioDuration.mockResolvedValue(undefined);
service.extractMetadataAsync(7, '/tmp/book.m4b', 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndSave).toHaveBeenCalledWith(7, '/tmp/book.m4b', 'm4b');
expect(metadataService.extractAndAggregateAudioDuration).toHaveBeenCalledWith(7, '/tmp/book.m4b');
});
it('extractMetadataAsync skips duration extraction for non-audio formats', async () => {
metadataService.extractAndSave.mockResolvedValue(undefined);
service.extractMetadataAsync(7, '/tmp/book.epub', 'epub');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndSave).toHaveBeenCalledWith(7, '/tmp/book.epub', 'epub');
expect(metadataService.extractAndAggregateAudioDuration).not.toHaveBeenCalled();
});
it('extractMetadataAsync does not extract duration when metadata save fails for an audio file', async () => {
vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
metadataService.extractAndSave.mockRejectedValue(new Error('probe failed'));
service.extractMetadataAsync(7, '/tmp/book.m4b', 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndAggregateAudioDuration).not.toHaveBeenCalled();
});
it('extractAudioDurationAsync ignores non-audio formats', () => {
service.extractAudioDurationAsync(7, '/tmp/book.epub', 'epub');
expect(metadataService.extractAndAggregateAudioDuration).not.toHaveBeenCalled();
});
it('extractAudioDurationAsync extracts and aggregates duration for audio formats', async () => {
metadataService.extractAndAggregateAudioDuration.mockResolvedValue(undefined);
service.extractAudioDurationAsync(7, '/tmp/chapter-01.mp3', 'mp3');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndAggregateAudioDuration).toHaveBeenCalledWith(7, '/tmp/chapter-01.mp3');
});
it('extractAudioDurationAsync logs and suppresses extraction errors', async () => {
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
metadataService.extractAndAggregateAudioDuration.mockRejectedValue(new Error('aggregate failed'));
service.extractAudioDurationAsync(7, '/tmp/chapter-01.mp3', 'mp3');
await new Promise((resolve) => setImmediate(resolve));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('aggregate failed'));
});
it('extractAddedAudioChaptersAsync ignores non-audio formats', () => {
service.extractAddedAudioChaptersAsync(7, 'epub');
expect(db.select).not.toHaveBeenCalled();
});
it('extractAddedAudioChaptersAsync extracts chapters for a single audio file', async () => {
metadataService.extractAudioChaptersAndNarrators.mockResolvedValue(undefined);
service.extractAddedAudioChaptersAsync(7, 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAudioChaptersAndNarrators).toHaveBeenCalledWith(7, '/path/to/library/book.m4b', 'm4b');
expect(metadataService.extractMergedAudioChapters).not.toHaveBeenCalled();
});
it('extractAddedAudioChaptersAsync merges chapters across files in playback order', async () => {
audioFilesWhere.mockResolvedValue([
{ absolutePath: '/path/to/library/10.mp3', format: 'mp3' },
{ absolutePath: '/path/to/library/2.mp3', format: 'mp3' },
]);
metadataService.extractAudioChaptersAndNarrators.mockResolvedValue(undefined);
metadataService.extractMergedAudioChapters.mockResolvedValue(undefined);
service.extractAddedAudioChaptersAsync(7, 'mp3');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAudioChaptersAndNarrators).toHaveBeenCalledWith(7, '/path/to/library/2.mp3', 'mp3');
expect(metadataService.extractMergedAudioChapters).toHaveBeenCalledWith(7, ['/path/to/library/2.mp3', '/path/to/library/10.mp3'], {
filesChanged: true,
});
});
it('extractAddedAudioChaptersAsync logs and suppresses extraction errors', async () => {
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
metadataService.extractAudioChaptersAndNarrators.mockRejectedValue(new Error('chapter probe failed'));
service.extractAddedAudioChaptersAsync(7, 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('chapter probe failed'));
});
describe('with undefined orchestrator', () => {
let serviceNoOrch: UploadProcessorService;
beforeEach(() => {
metadataService.extractAndSave.mockResolvedValue(undefined);
serviceNoOrch = new UploadProcessorService(db as any, metadataService as any, coverStore as any, undefined);
});
it('createBookRecord completes without scheduling when orchestrator is undefined', async () => {
const result = await serviceNoOrch.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
expect(result).toEqual({ bookId: 42, created: true });
expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled();
});
it('extractMetadataAsync handles supported format boundary', async () => {
serviceNoOrch.extractMetadataAsync(1, '/tmp/file.azw', 'azw');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAndSave).toHaveBeenCalledWith(1, '/tmp/file.azw', 'azw');
});
});
/**
* Filing a 31-track audiobook through `createBookRecord` opened 31 transactions, so a failure on
* track three left the first two behind as a book nobody asked for.
*/
describe('createUnitBookRecords', () => {
const unitFiles = [
{ folderPath: '/library/Book', absolutePath: '/library/Book/track-01.mp3', relPath: 'Book/track-01.mp3', format: 'mp3', sizeBytes: 10 },
{ folderPath: '/library/Book', absolutePath: '/library/Book/track-02.mp3', relPath: 'Book/track-02.mp3', format: 'mp3', sizeBytes: 20 },
];
it('writes every file of the unit inside one transaction', async () => {
insertBooksReturning.mockResolvedValueOnce([{ id: 42 }]);
// The book created for the first file is found again for the second.
selectLimit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 42 }])
.mockResolvedValueOnce([]);
const result = await service.createUnitBookRecords(1, 2, unitFiles);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(result).toEqual({ bookIds: [42], createdBookIds: [42], attachedFileIds: [], replacedPrimaries: [] });
expect(insertBooksValues).toHaveBeenCalledTimes(1);
expect(insertBookFilesValues).toHaveBeenCalledTimes(2);
});
it('remembers file rows added to a book that was already there', async () => {
// Existing book, and no `book_files` row yet at that path.
selectLimit.mockResolvedValueOnce([{ id: 99 }]).mockResolvedValueOnce([]);
insertBookFilesReturning.mockResolvedValueOnce([{ id: 777 }]);
const result = await service.createUnitBookRecords(1, 2, [unitFiles[0]!]);
expect(result).toEqual({ bookIds: [99], createdBookIds: [], attachedFileIds: [777], replacedPrimaries: [] });
});
it('leaves a row that already pointed at that path out of the rollback set', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99 }]).mockResolvedValueOnce([{ id: 777 }]);
const result = await service.createUnitBookRecords(1, 2, [unitFiles[0]!]);
expect(result).toEqual({ bookIds: [99], createdBookIds: [], attachedFileIds: [], replacedPrimaries: [] });
});
it('hashes every file before opening the transaction', async () => {
await service.createUnitBookRecords(1, 2, unitFiles);
expect(mockComputeFileHash).toHaveBeenCalledTimes(2);
expect(db.transaction).toHaveBeenCalledTimes(1);
});
it('renumbers the tracks of each book that received audio once every file of the unit is in', async () => {
insertBooksReturning.mockResolvedValueOnce([{ id: 42 }]);
selectLimit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 42 }])
.mockResolvedValueOnce([]);
await service.createUnitBookRecords(1, 2, unitFiles);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 42);
expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(Math.max(...insertBookFilesValues.mock.invocationCallOrder));
});
it('renumbers a book the unit joined, whose tracks the unit numbered from zero', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]).mockResolvedValueOnce([]);
await service.createUnitBookRecords(1, 2, [{ ...unitFiles[0]!, sortOrder: 0 }]);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 99);
});
it('does not renumber a book that only received files that are not audio', async () => {
selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['epub'] }]).mockResolvedValueOnce([]);
await service.createUnitBookRecords(1, 2, [
{ folderPath: '/library/Book', absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', format: 'epub', sizeBytes: 10 },
]);
expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled();
});
it('rolls the whole unit back when the renumbering fails', async () => {
mockRealignAudioTrackOrder.mockRejectedValueOnce(new Error('deadlock detected'));
await expect(service.createUnitBookRecords(1, 2, unitFiles)).rejects.toThrow('deadlock detected');
});
it('refuses an empty unit rather than creating a book with no files', async () => {
await expect(service.createUnitBookRecords(1, 2, [])).rejects.toBeInstanceOf(InternalServerErrorException);
});
});
/**
* A file that joins a book already in the library has to be ranked against the files there. The
* book used to keep whichever file created it, so an ebook that arrived first stayed primary in
* a library that ranks audiobooks first.
*/
describe('ranking the primary of a book a file joins', () => {
const audioFirst = ['m4b', 'mp3', 'epub', 'pdf'];
const epubFirst = ['epub', 'pdf', 'm4b', 'mp3'];
const joinedBook = (overrides: Record<string, unknown> = {}) => ({
id: 99,
primaryFileId: 500,
status: 'present',
formatPriority: audioFirst,
...overrides,
});
const ebook = { id: 500, format: 'epub', sizeBytes: 1000, mediaOverlayAvailable: false };
const audiobook = { id: 777, format: 'm4b', sizeBytes: 5000, mediaOverlayAvailable: false };
function joinExistingBook(book: ReturnType<typeof joinedBook>, contentFiles: unknown[], newFileId = 777) {
// The locked book lookup, then no `book_files` row yet at the new path.
selectLimit.mockResolvedValueOnce([book]).mockResolvedValueOnce([]);
insertBookFilesReturning.mockResolvedValueOnce([{ id: newFileId }]);
selectOrderBy.mockResolvedValueOnce(contentFiles);
}
it('makes the audiobook primary when it joins an ebook in a library that ranks audio first', async () => {
joinExistingBook(joinedBook(), [ebook, audiobook]);
const result = await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 5000);
expect(result).toEqual({ bookId: 99, created: false });
expect(updateBooksSet).toHaveBeenCalledTimes(1);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 777, updatedAt: expect.any(Date) });
});
it('keeps the ebook primary when the library ranks ebooks first', async () => {
joinExistingBook(joinedBook({ formatPriority: epubFirst }), [ebook, audiobook]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 5000);
expect(updateBooksSet).not.toHaveBeenCalled();
});
it('keeps the current primary when the joining file only ties with it', async () => {
// Plain ranking would take the lowest id among equal EPUBs; the book's own primary has to win the tie.
const older = { id: 450, format: 'epub', sizeBytes: 900, mediaOverlayAvailable: false };
const primary = { ...ebook, id: 800 };
const joining = { ...ebook, id: 900 };
joinExistingBook(joinedBook({ primaryFileId: 800, formatPriority: epubFirst }), [older, primary, joining], 900);
await service.createBookRecord(1, 2, '/folder', '/folder/other.epub', 'book/other.epub', 'epub', 1000);
expect(updateBooksSet).not.toHaveBeenCalled();
});
it('prefers a joining read-along EPUB over a plain one, as the scanner does', async () => {
const readAlong = { id: 900, format: 'epub', sizeBytes: 3000, mediaOverlayAvailable: true };
joinExistingBook(joinedBook({ formatPriority: epubFirst }), [ebook, readAlong], 900);
await service.createBookRecord(1, 2, '/folder', '/folder/readalong.epub', 'book/readalong.epub', 'epub', 3000);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 900, updatedAt: expect.any(Date) });
});
it('gives a book with no primary one', async () => {
joinExistingBook(joinedBook({ primaryFileId: null }), [audiobook]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 5000);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 777, updatedAt: expect.any(Date) });
});
it('keeps the primary rather than clearing it when every file is empty', async () => {
joinExistingBook(joinedBook(), [
{ ...ebook, sizeBytes: 0 },
{ ...audiobook, sizeBytes: 0 },
]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 0);
expect(updateBooksSet).not.toHaveBeenCalled();
});
it('leaves a missing book alone, since its other rows point at files that are gone', async () => {
joinExistingBook(joinedBook({ status: 'missing' }), [ebook, audiobook]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 5000);
expect(selectOrderBy).not.toHaveBeenCalled();
expect(updateBooksSet).not.toHaveBeenCalled();
});
it('does not re-rank for a file that is not content', async () => {
joinExistingBook(joinedBook(), [ebook, audiobook]);
await service.createBookRecord(1, 2, '/folder', '/folder/cover.jpg', 'book/cover.jpg', 'jpg', 50, { role: 'cover' });
expect(selectOrderBy).not.toHaveBeenCalled();
expect(updateBooksSet).not.toHaveBeenCalled();
});
it('does not re-rank a book this call created', async () => {
await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 12345);
expect(selectOrderBy).not.toHaveBeenCalled();
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 420 });
});
it('locks the book it joins until the transaction ends', async () => {
joinExistingBook(joinedBook(), [ebook, audiobook]);
await service.createBookRecord(1, 2, '/folder', '/folder/book.m4b', 'book/book.m4b', 'm4b', 5000);
expect(selectInnerJoin).toHaveBeenCalledTimes(1);
expect(selectFor).toHaveBeenCalledWith('update', { of: books });
});
it('reports the primary a unit replaced so a rollback can put it back', async () => {
joinExistingBook(joinedBook(), [ebook, audiobook]);
const result = await service.createUnitBookRecords(1, 2, [
{ folderPath: '/folder', absolutePath: '/folder/book.m4b', relPath: 'book/book.m4b', format: 'm4b', sizeBytes: 5000 },
]);
expect(result).toEqual({
bookIds: [99],
createdBookIds: [],
attachedFileIds: [777],
replacedPrimaries: [{ bookId: 99, previousPrimaryFileId: 500, primaryFileId: 777 }],
});
});
it('ranks a multi-file unit once, after every file is in', async () => {
const trackOne = { ...audiobook, id: 777, format: 'mp3' };
const trackTwo = { ...audiobook, id: 778, format: 'mp3' };
selectLimit.mockResolvedValueOnce([joinedBook()]).mockResolvedValueOnce([]).mockResolvedValueOnce([joinedBook()]).mockResolvedValueOnce([]);
insertBookFilesReturning.mockResolvedValueOnce([{ id: 777 }]).mockResolvedValueOnce([{ id: 778 }]);
selectOrderBy.mockResolvedValueOnce([ebook, trackOne, trackTwo]);
const result = await service.createUnitBookRecords(1, 2, [
{ folderPath: '/folder', absolutePath: '/folder/01.mp3', relPath: 'book/01.mp3', format: 'mp3', sizeBytes: 5000, sortOrder: 1 },
{ folderPath: '/folder', absolutePath: '/folder/02.mp3', relPath: 'book/02.mp3', format: 'mp3', sizeBytes: 5000, sortOrder: 2 },
]);
expect(selectOrderBy).toHaveBeenCalledTimes(1);
expect(updateBooksSet).toHaveBeenCalledTimes(1);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 777, updatedAt: expect.any(Date) });
expect(result.replacedPrimaries).toEqual([{ bookId: 99, previousPrimaryFileId: 500, primaryFileId: 777 }]);
});
it('does not re-rank a book the unit itself created', async () => {
insertBooksReturning.mockResolvedValueOnce([{ id: 42 }]);
selectLimit
.mockResolvedValueOnce([])
.mockResolvedValueOnce([joinedBook({ id: 42, primaryFileId: 420 })])
.mockResolvedValueOnce([]);
const result = await service.createUnitBookRecords(1, 2, [
{ folderPath: '/folder', absolutePath: '/folder/book.epub', relPath: 'book/book.epub', format: 'epub', sizeBytes: 1000 },
{ folderPath: '/folder', absolutePath: '/folder/book.m4b', relPath: 'book/book.m4b', format: 'm4b', sizeBytes: 5000 },
]);
expect(selectOrderBy).not.toHaveBeenCalled();
expect(result.replacedPrimaries).toEqual([]);
});
});
describe('deleteUnitBookRecords', () => {
it('clears the file rows before the books that own them', async () => {
await service.deleteUnitBookRecords({ bookIds: [42], createdBookIds: [42], attachedFileIds: [777], replacedPrimaries: [] });
expect(dialect.sqlToQuery(tx.execute.mock.calls[0]![0]).sql).toContain('delete from "book_files"');
expect(dialect.sqlToQuery(tx.execute.mock.calls[1]![0]).sql).toContain('delete from "book_files"');
expect(dialect.sqlToQuery(tx.execute.mock.calls[2]![0]).sql).toContain('delete from "books"');
});
const dialect = new PgDialect();
const restoreWhere = () => dialect.sqlToQuery(updateBooksWhere.mock.calls[0]![0] as SQL);
it('puts back a primary the unit replaced, after its rows are gone', async () => {
await service.deleteUnitBookRecords({
bookIds: [99],
createdBookIds: [],
attachedFileIds: [777],
replacedPrimaries: [{ bookId: 99, previousPrimaryFileId: 500, primaryFileId: 777 }],
});
expect(tx.execute).toHaveBeenCalledTimes(1);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 500, updatedAt: expect.any(Date) });
expect(tx.execute.mock.invocationCallOrder[0]).toBeLessThan(updateBooksSet.mock.invocationCallOrder[0]!);
// Only a primary that was cleared, or is still the one the unit chose, is undone, and only
// onto a file that is still this book's.
const { sql, params } = restoreWhere();
expect(sql).toMatch(/"primary_file_id" is null or "books"\."primary_file_id" = \$\d/);
expect(sql).toMatch(/EXISTS \(SELECT 1 FROM "book_files"/);
expect(params).toEqual(expect.arrayContaining([99, 777, 500]));
});
it('puts back an absent primary without requiring a file for it', async () => {
await service.deleteUnitBookRecords({
bookIds: [99],
createdBookIds: [],
attachedFileIds: [777],
replacedPrimaries: [{ bookId: 99, previousPrimaryFileId: null, primaryFileId: 777 }],
});
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: null, updatedAt: expect.any(Date) });
expect(restoreWhere().sql).not.toMatch(/EXISTS/);
});
it('puts back a replaced primary even when the unit added no rows of its own', async () => {
await service.deleteUnitBookRecords({
bookIds: [99],
createdBookIds: [],
attachedFileIds: [],
replacedPrimaries: [{ bookId: 99, previousPrimaryFileId: 500, primaryFileId: 777 }],
});
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(updateBooksSet).toHaveBeenCalledWith({ primaryFileId: 500, updatedAt: expect.any(Date) });
});
it('does nothing when the unit created nothing of its own', async () => {
await service.deleteUnitBookRecords({ bookIds: [99], createdBookIds: [], attachedFileIds: [], replacedPrimaries: [] });
expect(db.transaction).not.toHaveBeenCalled();
});
it('removes covers of the books it created and reconciles the books it only attached to', async () => {
const coverReconciler = { enqueue: vi.fn().mockResolvedValue(undefined) };
const withCovers = new UploadProcessorService(
db as any,
metadataService as any,
coverStore as any,
orchestrator as any,
coverReconciler as any,
);
await withCovers.deleteUnitBookRecords({ bookIds: [42, 7], createdBookIds: [42], attachedFileIds: [777], replacedPrimaries: [] });
expect(coverStore.removeCoverDirectory).toHaveBeenCalledWith(42);
expect(coverStore.removeCoverDirectory).not.toHaveBeenCalledWith(7);
expect(coverReconciler.enqueue).toHaveBeenCalledWith([7], { filesChanged: true });
});
});
it('reconciles cover slots once metadata extraction has written the file’s own cover', async () => {
const coverReconciler = { enqueue: vi.fn().mockResolvedValue(undefined) };
const withCovers = new UploadProcessorService(db as any, metadataService as any, coverStore as any, orchestrator as any, coverReconciler as any);
metadataService.extractAndSave.mockResolvedValue(undefined);
metadataService.extractAndAggregateAudioDuration.mockResolvedValue(undefined);
await withCovers.extractMetadata(7, '/tmp/book.m4b', 'm4b');
expect(coverReconciler.enqueue).toHaveBeenCalledWith([7], { filesChanged: true });
expect(coverReconciler.enqueue.mock.invocationCallOrder[0]).toBeGreaterThan(metadataService.extractAndSave.mock.invocationCallOrder[0]!);
});
it.each([
[true, 0],
[false, 1],
])('lets the library regex rules own extraction when they apply (%s)', async (handled, plainCalls) => {
const scanner = { extractMetadataWithLibraryRules: vi.fn().mockResolvedValue(handled) };
const withRules = new UploadProcessorService(
db as any,
metadataService as any,
coverStore as any,
orchestrator as any,
undefined,
scanner as any,
);
metadataService.extractAndSave.mockResolvedValue(undefined);
await withRules.extractMetadata(7, '/tmp/book.epub', 'epub');
expect(scanner.extractMetadataWithLibraryRules).toHaveBeenCalledWith(7);
expect(metadataService.extractAndSave).toHaveBeenCalledTimes(plainCalls);
});
});