vi.mock('fs/promises', () => ({ access: vi.fn(), lstat: vi.fn(), readdir: vi.fn(), readFile: vi.fn(), stat: vi.fn(), unlink: vi.fn(), rmdir: vi.fn().mockResolvedValue(undefined), })); import { BadRequestException, NotFoundException } from '@nestjs/common'; import { access, lstat, readdir, readFile, stat, unlink } from 'fs/promises'; import { DatabaseError } from 'pg'; import { DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, NotificationType, type BookDockMetadata, } from '@bookorbit/types'; import type { BookDockFileRow } from '../../db/schema/book-dock'; import { BookDockFinalizeService } from './book-dock-finalize.service'; const mockAccess = vi.mocked(access); const mockLstat = vi.mocked(lstat); const mockReaddir = vi.mocked(readdir); const mockReadFile = vi.mocked(readFile); const mockStat = vi.mocked(stat); const mockUnlink = vi.mocked(unlink); function makeService() { const db = { select: vi.fn(), update: vi.fn(), }; const repo = { findById: vi.fn(), countsByStatus: vi.fn(), findByIds: vi.fn(), findSelectionBatch: vi.fn(), findAllIds: vi.fn(), findExistingBooksByAbsolutePaths: vi.fn().mockResolvedValue([]), findUnitFiles: vi.fn().mockResolvedValue([]), deleteById: vi.fn(), deleteByIds: vi.fn(), }; const libraryService = { verifyUserAccess: vi.fn().mockResolvedValue(undefined), }; const appSettings = { getAutoFinalizeSettings: vi.fn(), getUploadPattern: vi.fn().mockResolvedValue(null), getUploadPatternBookPerFolder: vi.fn().mockResolvedValue(null), isCrossPlatformPathSanitizationEnabled: vi.fn().mockResolvedValue(false), getBookRequestImportFormats: vi.fn().mockResolvedValue('all'), }; const metadataService = { downloadAndSaveCover: vi.fn().mockResolvedValue(false), saveExtractedCoverBytes: vi.fn().mockResolvedValue(undefined), coverSlotIsEmpty: vi.fn().mockResolvedValue(true), replaceAuthors: vi.fn().mockResolvedValue(undefined), replaceGenres: vi.fn().mockResolvedValue(undefined), replaceNarrators: vi.fn().mockResolvedValue(undefined), upsertComicMetadata: vi.fn().mockResolvedValue(undefined), measureMissingAudioDurations: vi.fn().mockResolvedValue({ measured: 0, failed: 0 }), }; const metadataScoreService = { calculateAndSave: vi.fn().mockResolvedValue(undefined), }; const bookReadService = { replaceCommunityRatings: vi.fn().mockResolvedValue(undefined), }; const validator = { validateFormat: vi.fn(), sanitizeFilename: vi.fn((s: string) => s), }; const storage = { moveToPath: vi.fn().mockResolvedValue(undefined), }; const processor = { createUnitBookRecords: vi.fn().mockResolvedValue({ bookIds: [101], createdBookIds: [101], attachedFileIds: [] }), deleteUnitBookRecords: vi.fn().mockResolvedValue(undefined), reconcileCoversAsync: vi.fn(), }; const events = { on: vi.fn(), }; const gateway = { emitChanged: vi.fn(), }; const processingState = { isPaused: vi.fn().mockResolvedValue(false), getCachedPaused: vi.fn().mockReturnValue(false), }; const seriesMemberships = { replaceForBook: vi.fn().mockResolvedValue(undefined), syncPrimaryFromMetadata: vi.fn().mockResolvedValue(undefined), }; const notificationService = { notify: vi.fn().mockResolvedValue(undefined), }; const fileWriteService = { scheduleWrite: vi.fn(), }; const service = new BookDockFinalizeService( db as never, repo as never, libraryService as never, appSettings as never, metadataService as never, metadataScoreService as never, bookReadService as never, validator as never, storage as never, processor as never, events as never, gateway as never, notificationService as never, processingState as never, fileWriteService as never, undefined as never, seriesMemberships as never, ); return { service, db, repo, libraryService, appSettings, metadataService, metadataScoreService, bookReadService, validator, storage, processor, events, gateway, processingState, seriesMemberships, notificationService, fileWriteService, }; } function makeRow(overrides?: Partial>) { return { id: 1, fileName: 'book.epub', absolutePath: '/tmp/book.epub', fileSize: 100, format: 'epub', status: 'ready', uploadedBy: 7, embeddedMetadata: { title: 'Embedded Title', genres: ['Embedded Genre'] } as BookDockMetadata, selectedMetadata: null as BookDockMetadata | null, fetchedMetadata: null as BookDockMetadata | null, coverPath: null, targetLibraryId: null, targetFolderId: null, confidence: 90, fetchedMetadataSources: null, errorMessage: null, metadataEditedAt: null, autoFinalizeSuppressed: false, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } /** A book the shipped default patterns can fill every optional segment of. */ function defaultPatternMetadata(): BookDockMetadata { return { title: 'Dune', authors: ['Frank Herbert'], seriesName: 'Dune', seriesIndex: '1', publishedYear: 1965 } as BookDockMetadata; } describe('BookDockFinalizeService', () => { beforeEach(() => { vi.clearAllMocks(); mockAccess.mockReset(); mockReadFile.mockReset(); mockStat.mockReset(); mockUnlink.mockReset(); mockLstat.mockReset(); mockReaddir.mockReset(); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockReaddir.mockResolvedValue([]); mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); mockStat.mockResolvedValue({ size: 100 } as never); mockUnlink.mockResolvedValue(undefined); }); describe('triggerAutoFinalize', () => { it('does not load settings or rows while Book Dock processing is paused', async () => { const { service, repo, appSettings, processingState } = makeService(); processingState.isPaused.mockResolvedValue(true); const pauseSpy = vi.spyOn((service as any).autoFinalizeQueue, 'pause'); await service.triggerAutoFinalize(1); expect(pauseSpy).toHaveBeenCalledTimes(1); expect(appSettings.getAutoFinalizeSettings).not.toHaveBeenCalled(); expect(repo.findById).not.toHaveBeenCalled(); }); /** * A row another module owns. Racing it would either file the wrong book into the right * library or file it before that module's own verification ran, and both fail silently. */ it('skips a row whose finalization another module owns', async () => { const { service, repo, appSettings } = makeService(); const row = makeRow({ autoFinalizeSuppressed: true }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'safe_merge', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile'); await service.triggerAutoFinalize(row.id); expect(finalizeSpy).not.toHaveBeenCalled(); }); it('merges embedded and fetched metadata when auto-finalizing and selected metadata is empty', async () => { const { service, repo, appSettings } = makeService(); const fetched = { title: 'Fetched Title', authors: ['Fetched Author'] } as BookDockMetadata; const row = makeRow({ selectedMetadata: null, fetchedMetadata: fetched }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'safe_merge', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.triggerAutoFinalize(row.id); expect(finalizeSpy).toHaveBeenCalledTimes(1); const passedRow = finalizeSpy.mock.calls[0]?.[0] as { selectedMetadata: BookDockMetadata | null } | undefined; expect(passedRow?.selectedMetadata).toEqual({ title: 'Fetched Title', authors: ['Fetched Author'], genres: ['Embedded Genre'], }); }); it('lets selected metadata override fetched and embedded values during auto-finalize', async () => { const { service, repo, appSettings } = makeService(); const manual = { title: 'Manual Title' } as BookDockMetadata; const fetched = { title: 'Fetched Title', authors: ['Fetched Author'] } as BookDockMetadata; const row = makeRow({ selectedMetadata: manual, fetchedMetadata: fetched }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'safe_merge', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.triggerAutoFinalize(row.id); expect(finalizeSpy).toHaveBeenCalledTimes(1); const passedRow = finalizeSpy.mock.calls[0]?.[0] as { selectedMetadata: BookDockMetadata | null } | undefined; expect(passedRow?.selectedMetadata).toEqual({ title: 'Manual Title', authors: ['Fetched Author'], genres: ['Embedded Genre'], }); }); it('uses fetched metadata only (plus manual selection) in fetched_only mode', async () => { const { service, repo, appSettings } = makeService(); const manual = { title: 'Manual Title' } as BookDockMetadata; const fetched = { authors: ['Fetched Author'] } as BookDockMetadata; const row = makeRow({ selectedMetadata: manual, fetchedMetadata: fetched }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'fetched_only', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.triggerAutoFinalize(row.id); const passedRow = finalizeSpy.mock.calls[0]?.[0] as { selectedMetadata: BookDockMetadata | null } | undefined; expect(passedRow?.selectedMetadata).toEqual({ title: 'Manual Title', authors: ['Fetched Author'], }); }); it('uses embedded metadata only (plus manual selection) in embedded_only mode', async () => { const { service, repo, appSettings } = makeService(); const row = makeRow({ selectedMetadata: null, fetchedMetadata: { title: 'Fetched Title', authors: ['Fetched Author'] } as BookDockMetadata, }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'embedded_only', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.triggerAutoFinalize(row.id); const passedRow = finalizeSpy.mock.calls[0]?.[0] as { selectedMetadata: BookDockMetadata | null } | undefined; expect(passedRow?.selectedMetadata).toEqual({ title: 'Embedded Title', genres: ['Embedded Genre'], }); }); it('ignores confidence threshold in embedded_only mode', async () => { const { service, repo, appSettings } = makeService(); const row = makeRow({ confidence: null }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'embedded_only', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.triggerAutoFinalize(row.id); expect(finalizeSpy).toHaveBeenCalledTimes(1); }); it('still requires confidence threshold in fetched_only mode', async () => { const { service, repo, appSettings } = makeService(); const row = makeRow({ confidence: null, fetchedMetadata: { title: 'Fetched Title' } as BookDockMetadata }); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'fetched_only', }); repo.findById.mockResolvedValue(row); const finalizeSpy = vi.spyOn(service as never, 'finalizeFile').mockResolvedValue({ fileId: row.id, fileName: row.fileName, success: true, bookId: 42, } as never); await service.triggerAutoFinalize(row.id); expect(finalizeSpy).not.toHaveBeenCalled(); }); it('requeueAutoFinalizeCandidates queues ready rows that match current settings', async () => { const { service, repo, appSettings } = makeService(); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: true, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'safe_merge', }); repo.findSelectionBatch .mockResolvedValueOnce([ makeRow({ id: 1, status: 'ready', confidence: 90, fetchedMetadata: { title: 'One' } as BookDockMetadata }), makeRow({ id: 2, status: 'ready', confidence: 40, fetchedMetadata: { title: 'Two' } as BookDockMetadata }), ]) .mockResolvedValueOnce([]); const enqueueSpy = vi.spyOn((service as any).autoFinalizeQueue, 'enqueue').mockReturnValue(true); await expect(service.requeueAutoFinalizeCandidates()).resolves.toBe(1); expect(repo.findSelectionBatch).toHaveBeenCalledWith({ limit: 100, afterId: undefined, status: 'ready', userId: 0, canManageAll: true, }); expect(enqueueSpy).toHaveBeenCalledWith(1); expect(enqueueSpy).not.toHaveBeenCalledWith(2); }); it('requeueAutoFinalizeCandidates does no work when paused or disabled', async () => { const { service, repo, appSettings, processingState } = makeService(); processingState.isPaused.mockResolvedValueOnce(true); await expect(service.requeueAutoFinalizeCandidates()).resolves.toBe(0); expect(repo.findSelectionBatch).not.toHaveBeenCalled(); processingState.isPaused.mockResolvedValue(false); appSettings.getAutoFinalizeSettings.mockResolvedValue({ enabled: false, threshold: 85, libraryId: 5, folderId: 9, metadataMode: 'safe_merge' }); await expect(service.requeueAutoFinalizeCandidates()).resolves.toBe(0); expect(repo.findSelectionBatch).not.toHaveBeenCalled(); }); }); describe('finalize', () => { it('returns missing-row failures for explicit ids not found in repository', async () => { const { service, repo, notificationService } = makeService(); const rowOne = makeRow({ id: 1 }); repo.findByIds.mockResolvedValue([rowOne]); vi.spyOn(service as never, 'prepareFinalizeBatch').mockResolvedValue({ analyses: [{ fileId: 1, fileName: 'book.epub', row: rowOne, status: 'ready' }], existingDestinations: new Map(), } as never); vi.spyOn(service as never, 'finalizePreparedCandidate').mockResolvedValueOnce({ fileId: 1, fileName: 'book.epub', success: true, bookId: 101, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); const result = await service.finalize(7, true, true, [1, 2], false, [], 5, 9); expect(result.total).toBe(2); expect(result.succeeded).toBe(1); expect(result.failed).toBe(1); expect(result.results[1]).toEqual( expect.objectContaining({ fileId: 2, success: false, message: 'Book Dock file not found', }), ); expect(notificationService.notify).toHaveBeenCalledWith( expect.objectContaining({ type: NotificationType.BookDockFinalizedWithErrors, title: 'Book Dock finalization completed with errors', }), ); }); it('iterates selectAll batches until no rows remain', async () => { const { service, repo } = makeService(); repo.findSelectionBatch.mockResolvedValueOnce([makeRow({ id: 1 }), makeRow({ id: 2 })]).mockResolvedValueOnce([]); vi.spyOn(service as never, 'prepareFinalizeBatch').mockImplementation( (rows: unknown[]) => Promise.resolve({ analyses: rows.map((row: any) => ({ fileId: row.id, fileName: row.fileName, row, status: 'ready' })), existingDestinations: new Map(), }) as never, ); vi.spyOn(service as never, 'finalizePreparedCandidate').mockImplementation( (analysis: any) => Promise.resolve({ fileId: analysis.fileId, fileName: analysis.fileName, success: true, bookId: analysis.fileId + 9, }) as never, ); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); const result = await service.finalize(7, true, true, [], true, [], 5, 9, [], undefined, 'foo', undefined, true); expect(repo.findSelectionBatch).toHaveBeenCalledTimes(2); expect(repo.findSelectionBatch).toHaveBeenCalledWith(expect.objectContaining({ readyToFile: true })); expect(result.total).toBe(2); expect(result.failed).toBe(0); }); }); describe('finalizeFile', () => { it('fails early when destination library or folder is missing', async () => { const { service } = makeService(); await expect((service as any).finalizeFile(makeRow(), undefined, undefined, new Map(), 1, true)).resolves.toEqual({ fileId: 1, fileName: 'book.epub', success: false, message: 'Destination is not set for this file', }); }); it('fails when target file already exists at resolved destination', async () => { const { service, validator } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/existing.epub' as never); mockAccess.mockResolvedValueOnce(undefined as never); const result = await (service as any).finalizeFile( makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true, ); expect(result).toMatchObject({ fileId: 1, fileName: 'book.epub', success: false, message: 'A file with this name already exists at the target location', }); expect(validator.validateFormat).toHaveBeenCalled(); }); it('marks an occupied indexed destination as a duplicate', async () => { const { service, repo } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new.epub' as never); repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/new.epub', bookId: 77, libraryId: 5 }]); mockAccess.mockResolvedValueOnce(undefined as never); await expect( (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), ).resolves.toEqual( expect.objectContaining({ success: false, isDuplicate: true, existingBookId: 77, }), ); }); it('allows a stale indexed destination when the physical file is missing', async () => { const { service, repo, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/stale.epub' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/stale.epub', bookId: 77, libraryId: 5 }]); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); const result = await (service as any).finalizeFile( makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true, ); expect(result).toMatchObject({ success: true, bookId: 77 }); expect(result).not.toHaveProperty('isDuplicate'); }); it('rolls back moved files and reports failure when book record creation fails', async () => { const { service, storage, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 321 } as never); processor.createUnitBookRecords.mockRejectedValueOnce(new Error('create failed')); await expect( (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), ).resolves.toEqual( expect.objectContaining({ success: false, message: 'create failed', }), ); expect(storage.moveToPath).toHaveBeenNthCalledWith(1, '/tmp/book.epub', '/library/new/book.epub'); expect(storage.moveToPath).toHaveBeenNthCalledWith(2, '/library/new/book.epub', '/tmp/book.epub'); }); it('returns a friendly metadata validation message when book metadata constraints fail', async () => { const { service, storage, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 321 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [808], createdBookIds: [808], attachedFileIds: [] }); const constraintError = new Error('Failed query: update "book_metadata" set ...'); (constraintError as Error & { cause?: unknown }).cause = { code: '23514', constraint: 'book_metadata_published_year_range_chk', }; vi.spyOn(service as never, 'applyMetadata').mockRejectedValueOnce(constraintError as never); const result = await (service as any).finalizeFile( makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true, ); expect(result).toEqual( expect.objectContaining({ success: false, message: 'Invalid metadata: published year must be between 1000 and 2200.', }), ); expect(result.message).not.toContain('Failed query'); expect(storage.moveToPath).toHaveBeenNthCalledWith(1, '/tmp/book.epub', '/library/new/book.epub'); expect(storage.moveToPath).toHaveBeenNthCalledWith(2, '/library/new/book.epub', '/tmp/book.epub'); }); it('returns success with relative newName when finalize flow completes', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [555], createdBookIds: [555], attachedFileIds: [] }); await expect( (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), ).resolves.toEqual({ fileId: 1, fileName: 'book.epub', newName: 'new/book.epub', success: true, bookId: 555, }); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: '/library/new', absolutePath: '/library/new/book.epub', relPath: 'new/book.epub', format: 'epub', sizeBytes: 100, role: 'content', sortOrder: 0, }, ]); }); describe('as another format of an existing book', () => { const joinBook = { bookId: 42, folderPath: '/library/Frank Herbert/Dune' }; function stubFolderLibrary(service: BookDockFinalizeService, organizationMode = 'book_per_folder') { vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: [], fileNamingPattern: null, organizationMode, } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Frank Herbert/Dune (Unabridged)/Dune (Unabridged).m4b' as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); } const audioRow = () => makeRow({ fileName: 'dune.m4b', absolutePath: '/tmp/dune.m4b', format: 'm4b', targetLibraryId: 5, targetFolderId: 9 }); it("lands in the book's own folder under the pattern's file name, so the two formats are one book", async () => { const { service, processor, storage } = makeService(); stubFolderLibrary(service); const applyMetadata = vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); const applyJoined = vi.spyOn(service as never, 'applyJoinedMetadata').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [42], createdBookIds: [], attachedFileIds: [900] }); await expect((service as any).finalizeFile(audioRow(), undefined, undefined, new Map([[1, { joinBook }]]), 1, true)).resolves.toMatchObject({ success: true, bookId: 42, }); expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/dune.m4b', '/library/Frank Herbert/Dune/Dune (Unabridged).m4b'); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ expect.objectContaining({ folderPath: '/library/Frank Herbert/Dune', absolutePath: '/library/Frank Herbert/Dune/Dune (Unabridged).m4b' }), ]); // The book keeps the title and authors it already had. expect(applyJoined).toHaveBeenCalledWith(42, expect.objectContaining({ id: 1 }), null, null); expect(applyMetadata).not.toHaveBeenCalled(); }); /** Issue 1757: an audiobook joining an ebook was filed with no duration, so listening stayed at 0%. */ it('measures the audio durations of the book it joined', async () => { const { service, processor, metadataService } = makeService(); stubFolderLibrary(service); vi.spyOn(service as never, 'applyJoinedMetadata').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [42], createdBookIds: [], attachedFileIds: [900] }); await (service as any).finalizeFile(audioRow(), undefined, undefined, new Map([[1, { joinBook }]]), 1, true); expect(metadataService.measureMissingAudioDurations.mock.calls).toEqual([[42]]); }); it('files the ordinary way in a library where a book is a single file', async () => { const { service, storage } = makeService(); stubFolderLibrary(service, 'book_per_file'); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); await (service as any).finalizeFile(audioRow(), undefined, undefined, new Map([[1, { joinBook }]]), 1, true); expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/dune.m4b', '/library/Frank Herbert/Dune (Unabridged)/Dune (Unabridged).m4b'); }); it('refuses to place anything outside the folder being filed into', async () => { const { service, storage } = makeService(); stubFolderLibrary(service); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); await (service as any).finalizeFile( audioRow(), undefined, undefined, new Map([[1, { joinBook: { bookId: 42, folderPath: '/elsewhere/Dune' } }]]), 1, true, ); expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/dune.m4b', '/library/Frank Herbert/Dune (Unabridged)/Dune (Unabridged).m4b'); }); /** The book a format joins, as `applyJoinedMetadata` reads it: its audio facts and locks, then whether it has narrators. */ function mockJoinedBook( db: ReturnType['db'], current: { lockedFields?: string[]; durationSeconds?: number | null; abridged?: boolean | null; chapters?: unknown } = {}, hasNarrators = false, ) { const chain = (rows: unknown[]) => { const query = { from: vi.fn(), where: vi.fn(), limit: vi.fn().mockResolvedValue(rows) }; query.from.mockReturnValue(query); query.where.mockReturnValue(query); return query; }; db.select .mockReturnValueOnce(chain([{ lockedFields: [], durationSeconds: null, abridged: null, chapters: null, ...current }])) .mockReturnValueOnce(chain(hasNarrators ? [{ bookId: 42 }] : [])); const set = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }); db.update.mockReturnValue({ set }); return set; } it('adds identifiers and audio facts the book lacks without overwriting what it already says', async () => { const { service, db, metadataService } = makeService(); const set = mockJoinedBook(db, { durationSeconds: 3600, lockedFields: ['goodreadsId'] }); const row = makeRow({ format: 'm4b', embeddedMetadata: { durationSeconds: 9999, abridged: true, narrators: ['Simon Vance'] } as unknown as BookDockMetadata, selectedMetadata: { title: 'Dune (Unabridged)', authors: ['Simon Vance'], audibleId: 'B002V1OF70', goodreadsId: 'gr-1', } as BookDockMetadata, }); await (service as any).applyJoinedMetadata(42, row, null, null); const patch = set.mock.calls[0]![0] as Record; expect(patch).toHaveProperty('audibleId'); expect(patch).toHaveProperty('abridged', true); expect(patch).not.toHaveProperty('goodreadsId'); expect(patch).not.toHaveProperty('durationSeconds'); expect(patch).not.toHaveProperty('title'); expect(metadataService.replaceAuthors).not.toHaveBeenCalled(); expect(metadataService.replaceNarrators).toHaveBeenCalledWith(42, [{ name: 'Simon Vance', sortName: null }]); }); it('keeps the narrators a book already has, and any it has locked', async () => { const row = makeRow({ format: 'm4b', embeddedMetadata: { narrators: ['Simon Vance'] } as unknown as BookDockMetadata }); const existing = makeService(); mockJoinedBook(existing.db, {}, true); await (existing.service as any).applyJoinedMetadata(42, row, null, null); expect(existing.metadataService.replaceNarrators).not.toHaveBeenCalled(); const locked = makeService(); mockJoinedBook(locked.db, { lockedFields: ['narrators'] }); await (locked.service as any).applyJoinedMetadata(42, row, null, null); expect(locked.metadataService.replaceNarrators).not.toHaveBeenCalled(); }); it('uses a cover picked in the Book Dock for this file as it is', async () => { const { service, db, metadataService } = makeService(); mockJoinedBook(db); metadataService.downloadAndSaveCover.mockResolvedValueOnce(true); const row = makeRow({ format: 'm4b', coverPath: '/dock/covers/1.jpg', selectedMetadata: { title: 'Dune', coverUrl: 'https://covers.example/picked.jpg' } as BookDockMetadata, }); await (service as any).applyJoinedMetadata(42, row, 'https://covers.example/requested.jpg', null); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledTimes(1); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/picked.jpg' }], 42, 'audio', { userChosen: true, }); expect(metadataService.saveExtractedCoverBytes).not.toHaveBeenCalled(); }); /** * The dock's lookup does not know the file is the other format of a book it already has, so for * an audiobook it can answer with the ebook's portrait jacket. The file's own art goes in first, * and the looked-up cover replaces it only if it fits the slot, which the download checks. */ it("fills the new format's slot from the file's own art first, and offers the looked-up cover only to replace it if it fits", async () => { const { service, db, metadataService } = makeService(); mockJoinedBook(db); mockReadFile.mockResolvedValueOnce(Buffer.from('square-art') as never); const row = makeRow({ format: 'm4b', coverPath: '/dock/covers/1.jpg', selectedMetadata: { title: 'Dune' } as BookDockMetadata }); await (service as any).applyJoinedMetadata(42, row, 'https://covers.example/requested-square.jpg', 'https://covers.example/looked-up.jpg'); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(42, Buffer.from('square-art'), 'audio'); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledTimes(1); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/looked-up.jpg' }], 42, 'audio'); }); it("falls back to the cover chosen when the format was requested, held to the slot's shape", async () => { const { service, db, metadataService } = makeService(); mockJoinedBook(db); const row = makeRow({ format: 'm4b', coverPath: null, selectedMetadata: { title: 'Dune' } as BookDockMetadata }); await (service as any).applyJoinedMetadata(42, row, 'https://covers.example/requested-square.jpg', null); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledTimes(1); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/requested-square.jpg' }], 42, 'audio'); }); it('leaves a cover the book already holds for this medium, a dormant custom one included', async () => { const { service, db, metadataService } = makeService(); mockJoinedBook(db); metadataService.coverSlotIsEmpty.mockResolvedValueOnce(false); mockReadFile.mockResolvedValueOnce(Buffer.from('square-art') as never); const row = makeRow({ format: 'm4b', coverPath: '/dock/covers/1.jpg', selectedMetadata: { title: 'Dune' } as BookDockMetadata }); await (service as any).applyJoinedMetadata(42, row, 'https://covers.example/requested.jpg', 'https://covers.example/looked-up.jpg'); expect(metadataService.coverSlotIsEmpty).toHaveBeenCalledWith(42, 'audio'); expect(metadataService.saveExtractedCoverBytes).not.toHaveBeenCalled(); expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled(); }); }); it('files a book_per_file book under the pattern folders instead of the library root', async () => { const { service, appSettings, processor, storage } = makeService(); appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [557], createdBookIds: [557], attachedFileIds: [] }); const row = makeRow({ targetLibraryId: 5, targetFolderId: 9, selectedMetadata: defaultPatternMetadata() }); const destPath = '/library/Frank Herbert/Dune/01. Dune (1965).epub'; await expect((service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true)).resolves.toEqual({ fileId: 1, fileName: 'book.epub', newName: 'Frank Herbert/Dune/01. Dune (1965).epub', success: true, bookId: 557, }); expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/book.epub', destPath); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: destPath, absolutePath: destPath, relPath: 'Frank Herbert/Dune/01. Dune (1965).epub', format: 'epub', sizeBytes: 100, role: 'content', sortOrder: 0, }, ]); }); it('uses the file path as bookFolderPath in book_per_file mode', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/new/book.epub' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 100 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [556], createdBookIds: [556], attachedFileIds: [] }); await expect( (service as any).finalizeFile(makeRow({ targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, new Map(), 1, true), ).resolves.toMatchObject({ success: true, bookId: 556 }); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: '/library/new/book.epub', absolutePath: '/library/new/book.epub', relPath: 'new/book.epub', format: 'epub', sizeBytes: 100, role: 'content', sortOrder: 0, }, ]); }); it.each(['book_per_folder', 'book_per_file'])('keeps unrelated root imports separate in %s mode', async (organizationMode) => { const { service, processor, storage } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>', organizationMode, } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); const applyMetadata = vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValue({ size: 100 } as never); for (const [index, title] of ['First Book', 'Second Book'].entries()) { processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [101 + index], createdBookIds: [101 + index], attachedFileIds: [] }); const row = makeRow({ id: index + 1, targetLibraryId: 5, targetFolderId: 9, selectedMetadata: { title, authors: ['Test Author'], publishedYear: 2024 }, }); const result = await (service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true); expect(result).toMatchObject({ success: true, bookId: 101 + index, newName: `${title} - Test Author - 2024.epub` }); const destPath = `/library/${title} - Test Author - 2024.epub`; expect(storage.moveToPath).toHaveBeenNthCalledWith(index + 1, row.absolutePath, destPath); expect(processor.createUnitBookRecords).toHaveBeenNthCalledWith(index + 1, 5, 9, [ expect.objectContaining({ folderPath: destPath, absolutePath: destPath }), ]); expect(applyMetadata).toHaveBeenNthCalledWith(index + 1, 101 + index, row, true, null); } }); it('uses the persisted filename spelling for a root book identity', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: '{title}', organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValue({ size: 100 } as never); mockLstat.mockResolvedValue({} as never); mockReaddir.mockResolvedValue(['dune.epub'] as never); const result = await (service as any).finalizeFile( makeRow({ targetLibraryId: 5, targetFolderId: 9, selectedMetadata: { title: 'Dune' } }), undefined, undefined, new Map(), 1, true, ); expect(result).toMatchObject({ success: true, newName: 'dune.epub' }); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ expect.objectContaining({ folderPath: '/library/dune.epub', absolutePath: '/library/dune.epub' }), ]); }); describe('organization modes', () => { it('attaches pdf to existing book folder in book_per_folder mode', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub', 'pdf'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); // Naming pattern resolves PDF into the same folder as the existing EPUB vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Author/Dune/Dune.pdf' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 1024 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [42], createdBookIds: [42], attachedFileIds: [] }); const overrideMap = new Map([[7, { libraryId: 5, folderId: 9 }]]); const result = await (service as any).finalizeFile( makeRow({ id: 7, fileName: 'Dune.pdf', format: 'pdf', absolutePath: '/tmp/Dune.pdf', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, ); expect(result).toMatchObject({ success: true, bookId: 42 }); // book_per_folder: folderPath = dirname(destPath) = /library/Author/Dune expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: '/library/Author/Dune', absolutePath: '/library/Author/Dune/Dune.pdf', relPath: 'Author/Dune/Dune.pdf', format: 'pdf', sizeBytes: 1024, role: 'content', sortOrder: 0, }, ]); }); it('creates a separate book record in book_per_file mode', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 6, allowedFormats: ['epub', 'pdf'], fileNamingPattern: null, organizationMode: 'book_per_file', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 10, libraryId: 6, path: '/library2' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library2/Dune.pdf' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 2048 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [300], createdBookIds: [300], attachedFileIds: [] }); const overrideMap = new Map([[8, { libraryId: 6, folderId: 10 }]]); const result = await (service as any).finalizeFile( makeRow({ id: 8, fileName: 'Dune.pdf', format: 'pdf', absolutePath: '/tmp/Dune.pdf', targetLibraryId: 6, targetFolderId: 10 }), undefined, undefined, overrideMap, 1, true, { role: 'content', sortOrder: 0 }, ); expect(result).toMatchObject({ success: true, bookId: 300 }); // book_per_file: folderPath = destPath itself (each file is its own book) expect(processor.createUnitBookRecords).toHaveBeenCalledWith(6, 10, [ { folderPath: '/library2/Dune.pdf', absolutePath: '/library2/Dune.pdf', relPath: 'Dune.pdf', format: 'pdf', sizeBytes: 2048, role: 'content', sortOrder: 0, }, ]); }); }); }); describe('targetFileName override', () => { it('replaces the basename of the resolved destination with the sanitized targetFileName', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Unknown Author/1/1.epub' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 993 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [500], createdBookIds: [500], attachedFileIds: [] }); const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: '1_alt' }]]); const result = await (service as any).finalizeFile( makeRow({ id: 1, fileName: '1.epub', format: 'epub', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, ); expect(result).toMatchObject({ success: true, bookId: 500 }); // basename replaced: 1.epub → 1_alt.epub, directory unchanged expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: '/library/Unknown Author/1', absolutePath: '/library/Unknown Author/1/1_alt.epub', relPath: 'Unknown Author/1/1_alt.epub', format: 'epub', sizeBytes: 993, role: 'content', sortOrder: 0, }, ]); }); it('classifies an occupied indexed renamed destination as a duplicate', async () => { const { service, repo } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Unknown Author/1/1.epub' as never); repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/Unknown Author/1/1_alt.epub', bookId: 77, libraryId: 5 }]); mockAccess.mockResolvedValueOnce(undefined as never); const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: '1_alt' }]]); const result = await (service as any).finalizeFile( makeRow({ id: 1, fileName: '1.epub', format: 'epub', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, { role: 'content', sortOrder: 0 }, ); expect(result).toMatchObject({ success: false, isDuplicate: true, existingBookId: 77, }); expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith(['/library/Unknown Author/1/1_alt.epub']); }); it('targetFileName still fails when the renamed dest also already exists', async () => { const { service } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Unknown Author/1/1.epub' as never); // 1_alt.epub also already exists mockAccess.mockResolvedValueOnce(undefined as never); const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: '1_alt' }]]); const result = await (service as any).finalizeFile( makeRow({ id: 1, fileName: '1.epub', format: 'epub', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, ); expect(result).toMatchObject({ fileId: 1, fileName: '1.epub', success: false, message: 'A file with this name already exists at the target location', }); }); it('passes targetFileName through the finalize public API via overrides array', async () => { const { service, repo } = makeService(); repo.findByIds.mockResolvedValue([makeRow({ id: 4, targetLibraryId: 5, targetFolderId: 9 })]); const row = makeRow({ id: 4, targetLibraryId: 5, targetFolderId: 9 }); const prepareSpy = vi.spyOn(service as never, 'prepareFinalizeBatch').mockResolvedValue({ analyses: [{ fileId: 4, fileName: '1.epub', row, status: 'ready' }], existingDestinations: new Map(), } as never); vi.spyOn(service as never, 'finalizePreparedCandidate').mockResolvedValue({ fileId: 4, fileName: '1.epub', success: true, bookId: 99, } as never); vi.spyOn(service as never, 'emitChange').mockReturnValue(undefined as never); await service.finalize(1, true, true, [4], false, [], 5, 9, [{ fileId: 4, targetFileName: '1_alt' }]); const overrideMapPassed = prepareSpy.mock.calls[0]?.[3] as Map; expect(overrideMapPassed.get(4)).toMatchObject({ fileId: 4, targetFileName: '1_alt' }); }); it('strips extension from targetFileName if user includes it to avoid double extension', async () => { const { service, processor } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Author/Title/Title.epub' as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValueOnce({ size: 200 } as never); processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [600], createdBookIds: [600], attachedFileIds: [] }); // User types "Title (alt).epub" - should NOT produce "Title (alt).epub.epub" const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: 'Title (alt).epub' }]]); const result = await (service as any).finalizeFile( makeRow({ id: 1, fileName: 'Title.epub', format: 'epub', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, ); expect(result).toMatchObject({ success: true }); expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [ { folderPath: '/library/Author/Title', absolutePath: '/library/Author/Title/Title (alt).epub', relPath: 'Author/Title/Title (alt).epub', format: 'epub', sizeBytes: 200, role: 'content', sortOrder: 0, }, ]); }); it('rejects targetFileName that would escape the destination directory', async () => { const { service } = makeService(); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue('/library/Author/Title/Title.epub' as never); // Simulate a sanitizeFilename that still returns something with path separators // (defense-in-depth: guard fires even if upstream sanitization is incomplete) vi.spyOn((service as any).validator, 'sanitizeFilename').mockReturnValue('../escape.epub'); const overrideMap = new Map([[1, { libraryId: 5, folderId: 9, targetFileName: '../escape' }]]); const result = await (service as any).finalizeFile( makeRow({ id: 1, fileName: 'Title.epub', format: 'epub', targetLibraryId: 5, targetFolderId: 9 }), undefined, undefined, overrideMap, 1, true, { role: 'content', sortOrder: 0 }, ); expect(result).toEqual( expect.objectContaining({ success: false, message: 'Invalid file name', }), ); }); }); describe('finalize preflight and duplicate discard', () => { it('previews ready, duplicate, and destination conflict candidates', async () => { const { service, repo } = makeService(); const duplicateRow = makeRow({ id: 1, fileName: 'duplicate.epub', targetLibraryId: 5, targetFolderId: 9 }); const conflictRow = makeRow({ id: 2, fileName: 'conflict.epub', targetLibraryId: 5, targetFolderId: 9 }); const readyRow = makeRow({ id: 3, fileName: 'ready.epub', targetLibraryId: 5, targetFolderId: 9 }); repo.findByIds.mockResolvedValue([duplicateRow, conflictRow, readyRow]); repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/duplicate.epub', bookId: 77, libraryId: 5 }]); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockImplementation((_, __, row: { fileName: string }) => Promise.resolve(`/library/${row.fileName}`), ); mockAccess.mockImplementation((path) => { if (String(path).includes('ready.epub')) { return Promise.reject(Object.assign(new Error('not found'), { code: 'ENOENT' })) as never; } return Promise.resolve(undefined) as never; }); const preview = await service.previewFinalize(1, true, true, [1, 2, 3], false, [], undefined, undefined); expect(preview).toMatchObject({ total: 3, ready: 1, duplicates: 1, destinationConflicts: 1, missingDestination: 0, blocked: 0, }); expect(preview.items).toEqual( expect.arrayContaining([ expect.objectContaining({ fileId: 1, status: 'duplicate', existingBookId: 77 }), expect.objectContaining({ fileId: 2, status: 'destination_conflict' }), expect.objectContaining({ fileId: 3, status: 'ready' }), ]), ); expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledTimes(1); expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith([ '/library/duplicate.epub', '/library/conflict.epub', '/library/ready.epub', ]); }); it('discards only duplicate candidates and cleans their files', async () => { const { service, repo } = makeService(); const duplicateRow = makeRow({ id: 1, fileName: 'duplicate.epub', absolutePath: '/dock/duplicate.epub', targetLibraryId: 5, targetFolderId: 9, }); const conflictRow = makeRow({ id: 2, fileName: 'conflict.epub', absolutePath: '/dock/conflict.epub', targetLibraryId: 5, targetFolderId: 9 }); const readyRow = makeRow({ id: 3, fileName: 'ready.epub', absolutePath: '/dock/ready.epub', targetLibraryId: 5, targetFolderId: 9 }); repo.findByIds.mockResolvedValue([duplicateRow, conflictRow, readyRow]); repo.countsByStatus.mockResolvedValue({ pending: 0, ready: 2, error: 0, total: 2 }); repo.findExistingBooksByAbsolutePaths.mockResolvedValue([{ absolutePath: '/library/duplicate.epub', bookId: 77, libraryId: 5 }]); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], fileNamingPattern: null } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockImplementation((_, __, row: { fileName: string }) => Promise.resolve(`/library/${row.fileName}`), ); mockAccess.mockImplementation((path) => { if (String(path).includes('ready.epub')) { return Promise.reject(Object.assign(new Error('not found'), { code: 'ENOENT' })) as never; } return Promise.resolve(undefined) as never; }); const result = await service.discardDuplicateCandidates(1, true, true, [1, 2, 3], false, [], undefined, undefined); expect(result).toEqual({ total: 3, discarded: 1, skipped: 2, discardedFileIds: [1] }); expect(mockUnlink).toHaveBeenCalledWith('/dock/duplicate.epub'); expect(mockUnlink).not.toHaveBeenCalledWith('/dock/conflict.epub'); expect(mockUnlink).not.toHaveBeenCalledWith('/dock/ready.epub'); expect(repo.deleteByIds).toHaveBeenCalledWith([1]); }); }); it('previewNames returns [] for empty explicit selection', async () => { const { service } = makeService(); await expect(service.previewNames([], false, [], 5, 1, true)).resolves.toEqual([]); }); it('previewNames uses selectAll ids and preserves original filename when no pattern resolves', async () => { const { service, repo, appSettings, db } = makeService(); repo.findAllIds.mockResolvedValue([1]); repo.findByIds.mockResolvedValue([makeRow({ id: 1 })]); appSettings.getUploadPattern.mockResolvedValue(null); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }); await expect(service.previewNames([], true, [], undefined, 1, true, 'ready', 'title')).resolves.toEqual([ { fileId: 1, fileName: 'book.epub', newName: 'book.epub' }, ]); }); it('previewNames uses folder-mode global pattern for book_per_folder library', async () => { const { service, repo, appSettings, db } = makeService(); repo.findByIds.mockResolvedValue([makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: { title: 'Dune' } as BookDockMetadata })]); appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{title}/'); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 10, fileNamingPattern: null, organizationMode: 'book_per_folder' }]), }), }); const result = await service.previewNames([1], false, [], undefined, 1, true); expect(result[0].newName).toBe('Dune/book.epub'); }); it('previewNames uses file-mode global pattern for book_per_file library', async () => { const { service, repo, appSettings, db } = makeService(); repo.findByIds.mockResolvedValue([makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: { title: 'Dune' } as BookDockMetadata })]); appSettings.getUploadPattern.mockResolvedValue('{title}.{extension}'); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 10, fileNamingPattern: null, organizationMode: 'book_per_file' }]), }), }); const result = await service.previewNames([1], false, [], undefined, 1, true); expect(result[0].newName).toBe('Dune.epub'); }); it('previewNames library-specific pattern wins over mode-specific global pattern', async () => { const { service, repo, appSettings, db } = makeService(); repo.findByIds.mockResolvedValue([makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: { title: 'Dune' } as BookDockMetadata })]); appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{authors:first}/{title}/'); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 10, fileNamingPattern: '{title}.{extension}', organizationMode: 'book_per_folder' }]), }), }); const result = await service.previewNames([1], false, [], undefined, 1, true); expect(result[0].newName).toBe('Dune.epub'); }); it('previewNames marks a read-aloud EPUB and uses the same destination for finalization', async () => { const { service, repo, db } = makeService(); const row = makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: { title: 'Dune' } as BookDockMetadata }); const library = { id: 10, name: 'Books', fileNamingPattern: '{title}< ({readaloud})>', organizationMode: 'book_per_file' }; repo.findByIds.mockResolvedValue([row]); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([library]), }), }); const inspect = vi.spyOn(service as any, 'inspectMediaOverlayAvailable').mockResolvedValue(true); const [preview] = await service.previewNames([1], false, [], undefined, 1, true); const destination = await (service as any).resolveDestination(library, '/library', row, 'epub'); expect(preview.newName).toBe('Dune (readaloud).epub'); expect(destination).toBe('/library/Dune (readaloud).epub'); expect(inspect).toHaveBeenCalledTimes(2); }); it('previewNames sanitizes generated names when cross-platform mode is enabled', async () => { const { service, repo, appSettings, db } = makeService(); appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true); appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{title}'); repo.findByIds.mockResolvedValue([makeRow({ id: 1, selectedMetadata: { title: 'AUX', authors: ['CON'] } as BookDockMetadata })]); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), }), }); const result = await service.previewNames([1], false, [], undefined, 1, true); expect(result[0].newName).toBe('CON_/AUX_.epub'); }); it.each([ ['book_per_file', DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, 'Frank Herbert/Dune/01. Dune (1965).epub'], ['book_per_folder', DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, 'Frank Herbert/Dune/01. Dune (1965)/01. Dune (1965).epub'], ])('previewNames promises the destination finalize actually uses for a %s library', async (organizationMode, pattern, expected) => { const { service, repo, appSettings, db } = makeService(); const library = { id: 10, name: 'Books', fileNamingPattern: null, organizationMode }; const row = makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: defaultPatternMetadata() }); repo.findByIds.mockResolvedValue([row]); appSettings.getUploadPattern.mockResolvedValue(organizationMode === 'book_per_file' ? pattern : null); appSettings.getUploadPatternBookPerFolder.mockResolvedValue(organizationMode === 'book_per_folder' ? pattern : null); db.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([library]), }), }); const [preview] = await service.previewNames([1], false, [], undefined, 1, true); const destPath = await (service as any).resolveDestination(library, '/library', row, 'epub'); expect(preview.newName).toBe(expected); expect(destPath).toBe(`/library/${preview.newName}`); }); it('applyMetadata updates scalar metadata fields and related author/genre rows', async () => { const { service, db, metadataService, metadataScoreService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); const bucketRow = makeRow({ selectedMetadata: { title: 'New Title', subtitle: 'Sub', description: 'Desc', isbn13: '9780306406157', publisher: 'Pub', publishedYear: 2022, language: 'en-US', pageCount: 300, seriesName: 'Saga', seriesIndex: '2.5', authors: ['Author A'], genres: ['Fantasy'], } as BookDockMetadata, coverPath: null, }); await (service as any).applyMetadata(15, bucketRow); expect(updateChain.set).toHaveBeenCalledWith( expect.objectContaining({ title: 'New Title', subtitle: 'Sub', description: 'Desc', isbn13: '9780306406157', publisher: 'Pub', publishedYear: 2022, language: 'en-US', pageCount: 300, seriesName: 'Saga', seriesIndex: '2.5', }), ); expect(metadataService.replaceAuthors).toHaveBeenCalledWith(15, [{ name: 'Author A', sortName: null }]); expect(metadataService.replaceGenres).toHaveBeenCalledWith(15, ['Fantasy']); expect(metadataScoreService.calculateAndSave).toHaveBeenCalledWith(15); }); it('applyMetadata propagates score persistence failures', async () => { const { service, db, metadataScoreService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); metadataScoreService.calculateAndSave.mockRejectedValue(new Error('score failed')); await expect((service as any).applyMetadata(15, makeRow())).rejects.toThrow('score failed'); }); it('applyMetadata nulls publishedYear when it is outside database bounds', async () => { const { service, db } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 16, makeRow({ selectedMetadata: { title: 'The Black Company', publishedYear: 101, } as BookDockMetadata, coverPath: null, }), ); expect(updateChain.set).toHaveBeenCalledWith( expect.objectContaining({ publishedYear: null, }), ); }); it('applyMetadata preserves legacy fetched duration and strips rating timestamps during finalization', async () => { const { service, db, bookReadService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 18, makeRow({ coverPath: null, selectedMetadata: { title: "Harry Potter and the Sorcerer's Stone", duration: 31260, communityRatings: [ { provider: 'audible', rating: 4.78, ratingCount: 16077, updatedAt: '2026-07-22T05:10:27.373Z', }, ], } as unknown as BookDockMetadata, }), ); expect(updateChain.set).toHaveBeenCalledWith(expect.objectContaining({ durationSeconds: 31260 })); expect(bookReadService.replaceCommunityRatings).toHaveBeenCalledWith(18, [{ provider: 'audible', rating: 4.78, ratingCount: 16077 }]); }); it('applyMetadata persists provider IDs and every structured field emitted by metadata search', async () => { const { service, db, metadataService, bookReadService, seriesMemberships } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 19, makeRow({ coverPath: null, selectedMetadata: { title: 'Dune', pageCount: 688, narrators: ['Simon Vance'], durationSeconds: 1200, abridged: false, seriesMemberships: [ { seriesName: 'Dune', seriesIndex: '1' }, { seriesName: 'Dune Chronicles', seriesIndex: '1' }, ], communityRatings: [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }], googleBooksId: 'google-id', goodreadsId: 'goodreads-id', amazonId: 'amazon-id', hardcoverId: 'hardcover-book', hardcoverEditionId: 'hardcover-edition', openLibraryId: 'OL1W', itunesId: 'itunes-id', audibleId: 'audible-id', librofmId: 'librofm-id', koboId: 'kobo-id', comicvineId: 'comicvine-id', ranobedbId: 'ranobedb-id', lubimyczytacId: 'lubimyczytac-id', aladinId: 'aladin-id', comicMetadata: { issueNumber: '1', pencillers: ['Artist'] }, } as BookDockMetadata, }), ); expect(updateChain.set).toHaveBeenCalledWith( expect.objectContaining({ pageCount: 688, durationSeconds: 1200, abridged: false, googleBooksId: 'google-id', goodreadsId: 'goodreads-id', amazonId: 'amazon-id', hardcoverId: 'hardcover-book', hardcoverEditionId: 'hardcover-edition', openLibraryId: 'OL1W', itunesId: 'itunes-id', audibleId: 'audible-id', librofmId: 'librofm-id', koboId: 'kobo-id', comicvineId: 'comicvine-id', ranobedbId: 'ranobedb-id', lubimyczytacId: 'lubimyczytac-id', aladinId: 'aladin-id', }), ); expect(seriesMemberships.replaceForBook).toHaveBeenCalledWith(19, [ { seriesName: 'Dune', seriesIndex: '1' }, { seriesName: 'Dune Chronicles', seriesIndex: '1' }, ]); expect(bookReadService.replaceCommunityRatings).toHaveBeenCalledWith(19, [{ provider: 'hardcover', rating: 4.5, ratingCount: 1000 }]); expect(metadataService.upsertComicMetadata).toHaveBeenCalledWith(19, { issueNumber: '1', pencillers: ['Artist'] }); expect(metadataService.replaceNarrators).toHaveBeenCalledWith(19, [{ name: 'Simon Vance', sortName: null }]); }); it('applyMetadata prefers selected coverUrl and skips extracted cover copy when download succeeds', async () => { const { service, db, metadataService } = makeService(); metadataService.downloadAndSaveCover.mockResolvedValueOnce(true); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); mockReadFile.mockResolvedValue(Buffer.from('cover-bytes')); await (service as any).applyMetadata( 20, makeRow({ coverPath: '/tmp/cover.jpg', selectedMetadata: { title: 'T', coverUrl: 'https://covers.example/1.jpg' } as BookDockMetadata, }), ); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/1.jpg' }], 20, 'ebook', { userChosen: true }); expect(metadataService.saveExtractedCoverBytes).not.toHaveBeenCalled(); expect(mockReadFile).not.toHaveBeenCalled(); }); it('applyMetadata persists duration, chapters and narrators extracted from the audiobook', async () => { const { service, db, metadataService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 30, makeRow({ format: 'm4b', fileName: 'book.m4b', selectedMetadata: null, coverPath: null, embeddedMetadata: { title: 'Artificial Condition', authors: ['Martha Wells'], narrators: ['Kevin R. Free'], durationSeconds: 12218, chapters: [ { title: 'Chapter 1', startMs: 0 }, { title: 'Chapter 2', startMs: 804850 }, ], } as BookDockMetadata, }), ); expect(updateChain.set).toHaveBeenCalledWith( expect.objectContaining({ durationSeconds: 12218, chapters: [ { title: 'Chapter 1', startMs: 0 }, { title: 'Chapter 2', startMs: 804850 }, ], }), ); expect(metadataService.replaceAuthors).toHaveBeenCalledWith(30, [{ name: 'Martha Wells', sortName: null }]); expect(metadataService.replaceNarrators).toHaveBeenCalledWith(30, [{ name: 'Kevin R. Free', sortName: null }]); }); it('applyMetadata keeps audio facts from embeddedMetadata even when scalar fields were edited', async () => { const { service, db, metadataService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 31, makeRow({ format: 'm4b', selectedMetadata: { title: 'Edited Title' } as BookDockMetadata, coverPath: null, embeddedMetadata: { title: 'Original Title', durationSeconds: 555, narrators: ['Reader One'], chapters: [{ title: 'Intro', startMs: 10 }], } as BookDockMetadata, }), ); expect(updateChain.set).toHaveBeenCalledWith( expect.objectContaining({ title: 'Edited Title', durationSeconds: 555, chapters: [{ title: 'Intro', startMs: 10 }], }), ); expect(metadataService.replaceNarrators).toHaveBeenCalledWith(31, [{ name: 'Reader One', sortName: null }]); }); it('applyMetadata omits audio fields and skips narrators when none were extracted', async () => { const { service, db, metadataService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 32, makeRow({ selectedMetadata: null, coverPath: null, embeddedMetadata: { title: 'Just a Book' } as BookDockMetadata, }), ); const patch = updateChain.set.mock.calls[0]?.[0] as Record; expect(patch).not.toHaveProperty('durationSeconds'); expect(patch).not.toHaveProperty('chapters'); expect(metadataService.replaceNarrators).not.toHaveBeenCalled(); }); it('applyMetadata sanitizes malformed chapters and drops non-positive duration before persisting', async () => { const { service, db, metadataService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 33, makeRow({ selectedMetadata: null, coverPath: null, embeddedMetadata: { durationSeconds: 0, chapters: [ { title: 'Good', startMs: 1000 }, { title: 'NoStart' }, { title: 'Negative', startMs: -5 }, { startMs: 2000 }, 'garbage', { title: 'Stringy', startMs: '3000.7' }, ], } as unknown as BookDockMetadata, }), ); const patch = updateChain.set.mock.calls[0]?.[0] as Record; expect(patch).not.toHaveProperty('durationSeconds'); expect(patch.chapters).toEqual([ { title: 'Good', startMs: 1000 }, { title: '', startMs: 2000 }, { title: 'Stringy', startMs: 3001 }, ]); expect(metadataService.replaceNarrators).not.toHaveBeenCalled(); }); it('applyMetadata coerces a string-typed duration into a rounded integer', async () => { const { service, db } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); await (service as any).applyMetadata( 34, makeRow({ selectedMetadata: null, coverPath: null, embeddedMetadata: { durationSeconds: '999.6' } as unknown as BookDockMetadata, }), ); expect(updateChain.set).toHaveBeenCalledWith(expect.objectContaining({ durationSeconds: 1000 })); }); it('applyMetadata writes the staged cover of an audiobook into the audio slot, and never into a sibling book', async () => { const { service, db, metadataService } = makeService(); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined) }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); mockReadFile.mockResolvedValue(Buffer.from('square-art')); await (service as any).applyMetadata( 31, makeRow({ coverPath: '/tmp/cover.jpg', format: 'm4b', selectedMetadata: { title: 'T' } as BookDockMetadata }), ); await (service as any).applyMetadata( 32, makeRow({ coverPath: '/tmp/cover.jpg', format: 'm4b', selectedMetadata: { title: 'T' } as BookDockMetadata }), false, ); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledOnce(); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(31, Buffer.from('square-art'), 'audio'); }); it('applyMetadata falls back to extracted cover bytes when cover download is unavailable', async () => { const { service, db, metadataService } = makeService(); metadataService.downloadAndSaveCover.mockResolvedValueOnce(false); const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined), }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); mockReadFile.mockResolvedValueOnce(Buffer.from('cover-bytes')); await (service as any).applyMetadata( 21, makeRow({ coverPath: '/tmp/cover.jpg', selectedMetadata: { title: 'T', coverUrl: 'https://covers.example/1.jpg' } as BookDockMetadata, }), ); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(21, Buffer.from('cover-bytes'), 'ebook'); }); describe('a cover the metadata lookup found by itself', () => { function stubMetadataWrites(db: ReturnType['db']) { const updateChain = { set: vi.fn(), where: vi.fn().mockResolvedValue(undefined) }; updateChain.set.mockReturnValue(updateChain); db.update.mockReturnValue(updateChain); } it("puts an audiobook's own art in first, and lets the field rules' cover replace it only if it fits", async () => { const { service, db, metadataService } = makeService(); stubMetadataWrites(db); mockReadFile.mockResolvedValue(Buffer.from('square-art')); await (service as any).applyMetadata( 40, makeRow({ coverPath: '/tmp/cover.jpg', format: 'm4b', selectedMetadata: { title: 'Dear Debbie' } as BookDockMetadata }), true, 'https://covers.example/print-jacket.jpg', ); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(40, Buffer.from('square-art'), 'audio'); // Not `userChosen`: the shape check is what keeps a portrait jacket out of a filled square slot. expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/print-jacket.jpg' }], 40, 'audio'); expect(metadataService.saveExtractedCoverBytes.mock.invocationCallOrder[0]).toBeLessThan( metadataService.downloadAndSaveCover.mock.invocationCallOrder[0]!, ); }); it('checks the shape of a looked-up cover an audiobook falls back to, since nobody chose it', async () => { const { service, db, metadataService } = makeService(); stubMetadataWrites(db); await (service as any).applyMetadata( 41, makeRow({ coverPath: null, format: 'm4b', selectedMetadata: { title: 'Dear Debbie' } as BookDockMetadata }), true, 'https://covers.example/print-jacket.jpg', ); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/print-jacket.jpg' }], 41, 'audio'); }); it("files an e-book by the same rule: its own art, then the field rules' cover if it fits", async () => { const { service, db, metadataService } = makeService(); stubMetadataWrites(db); mockReadFile.mockResolvedValue(Buffer.from('epub-art')); await (service as any).applyMetadata( 42, makeRow({ coverPath: '/tmp/cover.jpg', selectedMetadata: { title: 'Dear Debbie' } as BookDockMetadata }), true, 'https://covers.example/jacket.jpg', ); expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith([{ url: 'https://covers.example/jacket.jpg' }], 42, 'ebook'); expect(metadataService.saveExtractedCoverBytes).toHaveBeenCalledWith(42, Buffer.from('epub-art'), 'ebook'); }); it('files a managed row with the looked-up cover kept apart from what it was told to file with', async () => { const { service, repo } = makeService(); repo.findById.mockResolvedValue( makeRow({ id: 9, format: 'm4b', embeddedMetadata: { title: 'Dear Debbie' } as BookDockMetadata, fetchedMetadata: { title: 'Dear Debbie', coverUrl: 'https://covers.example/print-jacket.jpg' } as BookDockMetadata, selectedMetadata: null, }), ); const finalizeFile = vi.spyOn(service as any, 'finalizeFile').mockResolvedValue({ fileId: 9, fileName: 'f', success: false, message: 'x' }); await service.finalizeManagedFile(9, { libraryId: 3, folderId: 4 }); const [row, , , overrides] = finalizeFile.mock.calls[0] as [ BookDockFileRow, unknown, unknown, Map, ]; expect(row.selectedMetadata?.coverUrl).toBeUndefined(); expect(row.selectedMetadata?.title).toBe('Dear Debbie'); expect(overrides.get(9)?.lookedUpCoverUrl).toBe('https://covers.example/print-jacket.jpg'); }); it('tries the requested art for a joined format only while its slot would otherwise stay empty', async () => { const { service, db, metadataService } = makeService(); stubMetadataWrites(db); const chain = () => { const query = { from: vi.fn(), where: vi.fn(), limit: vi.fn().mockResolvedValue([{ lockedFields: [], durationSeconds: null, abridged: null, chapters: null }]), }; query.from.mockReturnValue(query); query.where.mockReturnValue(query); return query; }; db.select.mockImplementation(chain); mockReadFile.mockResolvedValue(Buffer.from('square-art')); const row = makeRow({ coverPath: '/tmp/cover.jpg', format: 'm4b', selectedMetadata: { title: 'Dune' } as BookDockMetadata }); await (service as any).applyJoinedMetadata(43, row, 'https://covers.example/requested.jpg', 'https://covers.example/looked-up.jpg'); expect(metadataService.downloadAndSaveCover).toHaveBeenLastCalledWith([{ url: 'https://covers.example/looked-up.jpg' }], 43, 'audio'); await (service as any).applyJoinedMetadata( 44, { ...row, coverPath: null }, 'https://covers.example/requested.jpg', 'https://covers.example/looked-up.jpg', ); expect(metadataService.downloadAndSaveCover).toHaveBeenLastCalledWith( [{ url: 'https://covers.example/looked-up.jpg' }, { url: 'https://covers.example/requested.jpg' }], 44, 'audio', ); }); it('treats a cover picked in the Book Dock as chosen, whatever the lookup found', async () => { const { service, repo } = makeService(); repo.findById.mockResolvedValue( makeRow({ id: 9, fetchedMetadata: { title: 'Dune', coverUrl: 'https://covers.example/looked-up.jpg' } as BookDockMetadata, selectedMetadata: { title: 'Dune', coverUrl: 'https://covers.example/picked.jpg' } as BookDockMetadata, }), ); const finalizeFile = vi.spyOn(service as any, 'finalizeFile').mockResolvedValue({ fileId: 9, fileName: 'f', success: false, message: 'x' }); await service.finalizeManagedFile(9, {}); const [row, , , overrides] = finalizeFile.mock.calls[0] as [ BookDockFileRow, unknown, unknown, Map, ]; expect(row.selectedMetadata?.coverUrl).toBe('https://covers.example/picked.jpg'); expect(overrides.get(9)?.lookedUpCoverUrl).toBeNull(); }); }); describe('multi-file units', () => { const UNIT_DIR = '/dock/request-7-Neuromancer'; function unitRow(overrides: Record = {}) { return makeRow({ id: 42, fileName: 'track-01.mp3', absolutePath: `${UNIT_DIR}/track-01.mp3`, unitDirectory: UNIT_DIR, format: 'mp3', targetLibraryId: 5, targetFolderId: 9, embeddedMetadata: null, ...overrides, }); } const AUDIO_UNIT_FILES = [ { id: 1, dockFileId: 42, absolutePath: `${UNIT_DIR}/track-01.mp3`, fileName: 'track-01.mp3', fileSize: 10, format: 'mp3', role: 'content', sortOrder: 0, }, { id: 2, dockFileId: 42, absolutePath: `${UNIT_DIR}/track-02.mp3`, fileName: 'track-02.mp3', fileSize: 10, format: 'mp3', role: 'content', sortOrder: 1, }, { id: 3, dockFileId: 42, absolutePath: `${UNIT_DIR}/cover.jpg`, fileName: 'cover.jpg', fileSize: 5, format: 'jpg', role: 'cover', sortOrder: null, }, ]; function arrange( harness: ReturnType, options: { organizationMode?: string; formatPriority?: string[]; destPath?: string; importFormats?: string; fileWriteEnabled?: boolean; } = {}, ) { const { service } = harness; harness.appSettings.getBookRequestImportFormats.mockResolvedValue(options.importFormats ?? 'all'); vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, name: 'Loose', allowedFormats: [], fileNamingPattern: null, formatPriority: options.formatPriority ?? [], organizationMode: options.organizationMode ?? 'book_per_folder', fileWriteEnabled: options.fileWriteEnabled ?? false, } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockResolvedValue((options.destPath ?? '/library/Neuromancer/track-01.mp3') as never); vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never); vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never); mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); mockStat.mockResolvedValue({ size: 10 } as never); } function finalize(harness: ReturnType, row: ReturnType) { return (harness.service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true); } /** * Every file into one folder, and one book out of it, in a single write: the primary goes first * because the book row that carries `primaryFileId` is the one created for it. */ it.each([UNIT_DIR, null])('places every file into one book with directory ownership %s', async (unitDirectory) => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); const result = await finalize(harness, unitRow({ unitDirectory })); expect(result).toMatchObject({ success: true, bookId: 77 }); expect(harness.storage.moveToPath).toHaveBeenCalledTimes(3); expect(harness.processor.createUnitBookRecords).toHaveBeenCalledTimes(1); const [, , files] = harness.processor.createUnitBookRecords.mock.calls[0]; expect(new Set(files.map((file: { folderPath: string }) => file.folderPath)).size).toBe(1); expect(files[0].absolutePath).toContain('track-01.mp3'); expect(files.map((file: { role: string; sortOrder: number | null }) => ({ role: file.role, sortOrder: file.sortOrder }))).toEqual([ { role: 'content', sortOrder: 0 }, { role: 'content', sortOrder: 1 }, { role: 'cover', sortOrder: null }, ]); }); /** * The books commit before `applyMetadata` runs, and that runs against services which cannot * join the transaction. A ghost book plus a `book_files` row pointing at a path that has just * been moved back to the dock is the worst outcome this feature can produce. */ it('takes back the book rows it created when the metadata pass fails', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); const written = { bookIds: [77], createdBookIds: [77], attachedFileIds: [9] }; harness.processor.createUnitBookRecords.mockResolvedValue(written); vi.spyOn(harness.service as never, 'applyMetadata').mockRejectedValue(new Error('metadata exploded') as never); const result = await finalize(harness, unitRow()); expect(result.success).toBe(false); expect(harness.processor.deleteUnitBookRecords).toHaveBeenCalledWith(written); // And the files go back to the dock, so nothing is left half-filed on either side. expect(harness.storage.moveToPath.mock.calls.slice(3)).toHaveLength(3); }); /** A rollback that throws must not replace the error that caused it. */ it('reports the original failure even when the rollback itself fails', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); harness.processor.deleteUnitBookRecords.mockRejectedValue(new Error('rollback exploded')); vi.spyOn(harness.service as never, 'applyMetadata').mockRejectedValue(new Error('metadata exploded') as never); const result = await finalize(harness, unitRow()); expect(result).toMatchObject({ success: false, message: 'metadata exploded' }); }); /** * A disc-foldered unit holds two files called `track01.mp3`. Filing them both by basename put * the second on top of the first, and filed the book under `CD 1` rather than under the book. */ it.each([UNIT_DIR, null])('preserves distinct disc paths with directory ownership %s', async (unitDirectory) => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue([ { id: 1, dockFileId: 42, absolutePath: `${UNIT_DIR}/CD 1/track01.mp3`, fileName: 'track01.mp3', fileSize: 10, format: 'mp3', role: 'content', sortOrder: 0, }, { id: 2, dockFileId: 42, absolutePath: `${UNIT_DIR}/CD 2/track01.mp3`, fileName: 'track01.mp3', fileSize: 10, format: 'mp3', role: 'content', sortOrder: 1, }, ]); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [80], createdBookIds: [80], attachedFileIds: [] }); const result = await finalize(harness, unitRow({ absolutePath: `${UNIT_DIR}/CD 1/track01.mp3`, unitDirectory })); expect(result.success).toBe(true); expect(harness.storage.moveToPath.mock.calls.map((call: string[]) => call[1])).toEqual([ '/library/Neuromancer/CD 1/track01.mp3', '/library/Neuromancer/CD 2/track01.mp3', ]); // And the book is the unit's own folder, not the disc the primary happened to sit in. const [, , files] = harness.processor.createUnitBookRecords.mock.calls[0]; expect(new Set(files.map((file: { folderPath: string }) => file.folderPath))).toEqual(new Set(['/library/Neuromancer'])); }); /** Twenty of thirty-one files moved and then EXDEV must not leave a half-placed folder. */ it('puts back every file it moved when one of them fails', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.storage.moveToPath.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('EXDEV')); const result = await finalize(harness, unitRow()); expect(result.success).toBe(false); expect(harness.storage.moveToPath.mock.calls.slice(3)).toEqual([ [expect.stringContaining('track-01.mp3'), `${UNIT_DIR}/track-01.mp3`], [expect.stringContaining('track-02.mp3'), `${UNIT_DIR}/track-02.mp3`], ]); }); /** A folder in a book_per_file library is split apart by the next scan: data loss, not taste. */ it.each([UNIT_DIR, null])('holds multipart audio for a book_per_file library with directory ownership %s', async (unitDirectory) => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness, { organizationMode: 'book_per_file' }); const result = await finalize(harness, unitRow({ unitDirectory })); expect(result.success).toBe(false); expect(result.message).toContain('one book per file'); expect(harness.storage.moveToPath).not.toHaveBeenCalled(); }); it('discards every recorded file of a shared-folder unit without removing its directory', async () => { const { service, repo } = makeService(); repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); await (service as any).cleanupDiscardedBookDockFile(unitRow({ unitDirectory: null })); expect(new Set(mockUnlink.mock.calls.map(([path]) => path))).toEqual(new Set(AUDIO_UNIT_FILES.map((file) => file.absolutePath))); }); const MULTI_FORMAT_FILES = [ { id: 1, dockFileId: 42, absolutePath: '/dock/request-7-Dune/Dune.epub', fileName: 'Dune.epub', fileSize: 10, format: 'epub', role: 'content', sortOrder: 0, }, { id: 2, dockFileId: 42, absolutePath: '/dock/request-7-Dune/Dune.pdf', fileName: 'Dune.pdf', fileSize: 10, format: 'pdf', role: 'content', sortOrder: 1, }, { id: 3, dockFileId: 42, absolutePath: '/dock/request-7-Dune/cover.jpg', fileName: 'cover.jpg', fileSize: 5, format: 'jpg', role: 'cover', sortOrder: null, }, ]; function multiFormatRow() { return unitRow({ fileName: 'Dune.epub', absolutePath: '/dock/request-7-Dune/Dune.epub', format: 'epub' }); } it('keeps every format of one book when the setting says all available', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { destPath: '/library/Dune/Dune.epub', importFormats: 'all' }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [78], createdBookIds: [78], attachedFileIds: [] }); const result = await finalize(harness, multiFormatRow()); expect(result.success).toBe(true); expect(harness.storage.moveToPath).toHaveBeenCalledTimes(3); }); it('keeps only the library preferred format when the setting says preferred only', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { formatPriority: ['pdf', 'epub'], destPath: '/library/Dune/Dune.epub', importFormats: 'preferred' }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [78], createdBookIds: [78], attachedFileIds: [] }); const result = await finalize(harness, multiFormatRow()); expect(result.success).toBe(true); // The chosen format and the artwork that came with it; the format that lost is dropped. expect(harness.storage.moveToPath.mock.calls.map((call: string[]) => call[0])).toEqual([ '/dock/request-7-Dune/Dune.pdf', '/dock/request-7-Dune/cover.jpg', ]); }); /** * One book per file is exactly what that mode means, and what its own next scan would produce. * Both books get the metadata: a second book with the same title and none of it is worse than * not importing it. */ it('makes one book per format in a book_per_file library when keeping all formats', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', importFormats: 'all' }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [81, 82], createdBookIds: [81, 82], attachedFileIds: [] }); const applyMetadata = vi.spyOn(harness.service as never, 'applyMetadata').mockResolvedValue(undefined as never); const result = await finalize(harness, multiFormatRow()); expect(result).toMatchObject({ success: true, bookId: 81 }); // The cover has nowhere to live in a loose-file library, exactly as the scanner treats it. expect(harness.storage.moveToPath).toHaveBeenCalledTimes(2); const [, , looseFiles] = harness.processor.createUnitBookRecords.mock.calls[0]; expect(looseFiles.map((file: { folderPath: string }) => file.folderPath)).toEqual(['/library/Dune.epub', '/library/Dune.pdf']); expect(applyMetadata.mock.calls.map((call: unknown[]) => call[0])).toEqual([81, 82]); // The staged cover came from the primary file, so only its book gets it; reconcile fills the rest. expect(applyMetadata.mock.calls.map((call: unknown[]) => call[2])).toEqual([true, false]); expect(harness.processor.reconcileCoversAsync).toHaveBeenCalledWith([81, 82]); }); /** * Issue 1757: the player derives progress only from per-file durations, and finalize used to * leave them empty until the next scan, so a filed audiobook stayed at 0% and never reached * Continue Listening. */ describe('audio durations', () => { it('measures every book the unit produced, after its metadata and before any write-back', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [101, 102], attachedFileIds: [] }); const order: string[] = []; vi.spyOn(harness.service as never, 'applyMetadata').mockImplementation(((bookId: number) => { order.push(`metadata:${bookId}`); return Promise.resolve(); }) as never); harness.metadataService.measureMissingAudioDurations.mockImplementation((bookId: number) => { order.push(`measure:${bookId}`); return Promise.resolve({ measured: 1, failed: 0 }); }); harness.fileWriteService.scheduleWrite.mockImplementation((bookId: number) => { order.push(`write:${bookId}`); }); const result = await finalize(harness, multiFormatRow()); expect(result.success).toBe(true); expect(order).toEqual(['metadata:101', 'metadata:102', 'measure:101', 'measure:102', 'write:101', 'write:102']); }); it('measures the one book of a multi-track audiobook', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); const result = await finalize(harness, unitRow()); expect(result).toMatchObject({ success: true, bookId: 77 }); expect(harness.metadataService.measureMissingAudioDurations.mock.calls).toEqual([[77]]); }); it('keeps the book filed when measuring fails, and still measures the next book', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [101, 102], attachedFileIds: [] }); harness.metadataService.measureMissingAudioDurations .mockRejectedValueOnce(new Error('connection reset')) .mockResolvedValueOnce({ measured: 1, failed: 0 }); const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => undefined); const result = await finalize(harness, multiFormatRow()); expect(result).toMatchObject({ success: true, bookId: 101 }); expect(harness.metadataService.measureMissingAudioDurations.mock.calls).toEqual([[101], [102]]); expect(harness.processor.deleteUnitBookRecords).not.toHaveBeenCalled(); // Only the forward moves: nothing went back to the dock. expect(harness.storage.moveToPath).toHaveBeenCalledTimes(2); expect(harness.fileWriteService.scheduleWrite).toHaveBeenCalledTimes(2); expect(warn).toHaveBeenCalledWith( expect.stringMatching(/^\[book_dock\.measure_audio_durations\] \[fail\] fileId=42 bookId=101 .*errorClass=Error error="connection reset"/), ); }); it('logs files it could not measure without failing the import', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); harness.metadataService.measureMissingAudioDurations.mockResolvedValueOnce({ measured: 1, failed: 1 }); const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => undefined); const result = await finalize(harness, unitRow()); expect(result).toMatchObject({ success: true, bookId: 77 }); expect(warn).toHaveBeenCalledWith( expect.stringMatching(/^\[book_dock\.measure_audio_durations\] \[end\] fileId=42 bookId=77 .*measured=1 failed=1/), ); }); it('measures nothing when the metadata pass fails and the unit is taken back', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] }); vi.spyOn(harness.service as never, 'applyMetadata').mockRejectedValue(new Error('metadata exploded') as never); const result = await finalize(harness, unitRow()); expect(result.success).toBe(false); expect(harness.metadataService.measureMissingAudioDurations).not.toHaveBeenCalled(); }); it('measures nothing when the book rows cannot be written', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness); harness.processor.createUnitBookRecords.mockRejectedValue(new Error('create failed')); const result = await finalize(harness, unitRow()); expect(result.success).toBe(false); expect(harness.metadataService.measureMissingAudioDurations).not.toHaveBeenCalled(); }); }); describe('metadata write-back', () => { it('schedules a write for every book the unit produced, attributed to the uploader', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [101, 102], attachedFileIds: [] }); const result = await finalize(harness, multiFormatRow()); expect(result.success).toBe(true); expect(harness.fileWriteService.scheduleWrite.mock.calls).toEqual([ [101, 'auto', 7], [102, 'auto', 7], ]); }); it('does not schedule a write when the library does not write metadata to files', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness, { fileWriteEnabled: false }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101], createdBookIds: [101], attachedFileIds: [] }); const result = await finalize(harness, unitRow()); expect(result.success).toBe(true); expect(harness.fileWriteService.scheduleWrite).not.toHaveBeenCalled(); }); it('does not schedule a write for any book when a later book of the unit fails', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES); arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [102], attachedFileIds: [9] }); vi.spyOn(harness.service as never, 'applyMetadata') .mockResolvedValueOnce(undefined as never) .mockRejectedValueOnce(new Error('metadata exploded') as never); const result = await finalize(harness, multiFormatRow()); expect(result.success).toBe(false); expect(harness.processor.deleteUnitBookRecords).toHaveBeenCalled(); expect(harness.fileWriteService.scheduleWrite).not.toHaveBeenCalled(); }); it('does not schedule a write when dock record cleanup fails', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness, { fileWriteEnabled: true }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101], createdBookIds: [101], attachedFileIds: [] }); vi.spyOn(harness.service as never, 'cleanupBookDockRecord').mockRejectedValue(new Error('cleanup failed') as never); const result = await finalize(harness, unitRow()); expect(result.success).toBe(false); expect(harness.fileWriteService.scheduleWrite).not.toHaveBeenCalled(); }); }); /** * A pattern with no path separator resolves to a bare filename, and `dirname()` of that is the * library folder root. Without a floor every unit would land there and merge into one book. */ it('never places a unit directly in the library folder root', async () => { const harness = makeService(); harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES); arrange(harness, { destPath: '/library/track-01.mp3' }); harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [79], createdBookIds: [79], attachedFileIds: [] }); const result = await finalize(harness, unitRow()); expect(result.success).toBe(true); for (const call of harness.storage.moveToPath.mock.calls) { expect(call[1]).toContain('/library/track-01/'); } }); }); /** * A whole failed SELECT, table and column names included, once reached the request drawer of the * person who asked for the book. The cause belongs in the log, not in their status line. */ it('does not put a database error into the message a requester reads', async () => { const harness = makeService(); const row = makeRow({ targetLibraryId: 5, targetFolderId: 9, unitDirectory: '/dock/request-7-Dune' }); const databaseError = Object.assign(new DatabaseError('syntax error at or near "asc"', 0, 'error'), { code: '42601' }); const failure = Object.assign(new Error('Failed query: select "id" from "book_dock_unit_files" where ...'), { cause: databaseError, }); harness.repo.findUnitFiles.mockRejectedValue(failure); const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => {}); vi.spyOn(harness.service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, name: 'Books', allowedFormats: [], fileNamingPattern: null, formatPriority: [], organizationMode: 'book_per_folder', } as never); vi.spyOn(harness.service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); const result = await (harness.service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true); expect(result.success).toBe(false); expect(result.message).toBe('Filing this book failed inside BookOrbit. Check the server log for the cause.'); expect(result.message).not.toContain('select'); expect(result.message).not.toContain('book_dock_unit_files'); expect(warn).toHaveBeenCalledWith(expect.stringContaining('errorClass=DatabaseError errorCode=42601')); expect(warn).toHaveBeenCalledWith(expect.stringContaining('error="syntax error at or near \\"asc\\""')); expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('book_dock_unit_files')); }); it.each(['EXDEV', 'EPERM', 'EACCES', 'ENOENT'])('logs %s from a failed file move without exposing paths to the requester', async (code) => { const harness = makeService(); const row = makeRow({ absolutePath: '/dock/book.epub' }); const prepared = { fileId: row.id, fileName: row.fileName, row, status: 'ready', destPath: '/library/book.epub', library: { id: 5 }, folder: { id: 9, path: '/library' }, format: 'epub', placement: [ { sourcePath: '/dock/book.epub', destPath: '/library/book.epub', format: 'epub', role: 'content', sortOrder: 0, }, ], }; const failure = Object.assign(new Error(`${code}: move '/dock/book.epub' -> '/library/book.epub'`), { code }); harness.storage.moveToPath.mockRejectedValueOnce(failure); vi.spyOn(harness.service as never, 'classifyDestination').mockResolvedValue(prepared as never); const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => {}); const result = await (harness.service as any).finalizePreparedCandidate(prepared, new Map()); expect(result).toEqual({ fileId: 1, fileName: 'book.epub', success: false, message: 'Filing this book failed inside BookOrbit. Check the server log for the cause.', }); expect(result.message).not.toContain('/dock'); expect(result.message).not.toContain('/library'); expect(warn).toHaveBeenCalledExactlyOnceWith( `[book_dock.finalize] [fail] fileId=1 stage=filing errorClass=Error errorCode=${code} error="${code}" - Book Dock file finalization failed`, ); }); it('cleanupBookDockRecord deletes cover files and bucket row id', async () => { const { service, repo } = makeService(); mockUnlink.mockResolvedValue(undefined); await (service as any).cleanupBookDockRecord(makeRow({ id: 44, coverPath: '/tmp/cover.png' })); expect(mockUnlink).toHaveBeenCalledWith('/tmp/cover.png'); expect(mockUnlink).toHaveBeenCalledWith('/tmp/cover_thumb.jpg'); expect(repo.deleteById).toHaveBeenCalledWith(44); }); it('findLibraryOrFail and findFolderOrFail throw typed errors for invalid destination records', async () => { const { service, db } = makeService(); const selectChain = { from: vi.fn(), where: vi.fn(), limit: vi.fn(), }; selectChain.from.mockReturnValue(selectChain); selectChain.where.mockReturnValue(selectChain); selectChain.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 9, libraryId: 4, path: '/x' }]); db.select.mockReturnValue(selectChain); await expect((service as any).findLibraryOrFail(2)).rejects.toBeInstanceOf(NotFoundException); await expect((service as any).findFolderOrFail(9, 5)).rejects.toBeInstanceOf(BadRequestException); }); it('prepares one batched destination lookup and ignores matching metadata', async () => { const { service, repo } = makeService(); const rows = [ makeRow({ id: 1, fileName: 'one.epub', selectedMetadata: { title: 'Same', isbn13: '9780306406157' } }), makeRow({ id: 2, fileName: 'two.epub', selectedMetadata: { title: 'Same', isbn13: '9780306406157' } }), ]; vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({ id: 5, allowedFormats: ['epub'], organizationMode: 'book_per_file', } as never); vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never); vi.spyOn(service as never, 'resolveDestination').mockImplementation((_, __, row: { fileName: string }) => Promise.resolve(`/library/${row.fileName}`), ); const prepared = await (service as any).prepareFinalizeBatch(rows, 5, 9, new Map(), 1, true); expect(prepared.analyses.map((analysis: { status: string }) => analysis.status)).toEqual(['ready', 'ready']); expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledOnce(); expect(repo.findExistingBooksByAbsolutePaths).toHaveBeenCalledWith(['/library/one.epub', '/library/two.epub']); }); it('scopes indexed destination matches to the target library', async () => { const { service } = makeService(); const analysis = { fileId: 1, fileName: 'book.epub', row: makeRow(), status: 'ready', destPath: '/shared/book.epub', library: { id: 5 }, folder: { id: 9, path: '/shared' }, format: 'epub', }; mockAccess.mockResolvedValueOnce(undefined as never); const classified = await (service as any).classifyDestination(analysis, new Map([['6\u0000/shared/book.epub', 77]])); expect(classified).toMatchObject({ status: 'destination_conflict' }); expect(classified).not.toHaveProperty('existingBookId'); }); it('reports non-ENOENT destination access failures without moving the file', async () => { const { service, storage } = makeService(); const warn = vi.spyOn((service as any).logger, 'warn').mockImplementation(() => {}); const analysis = { fileId: 1, fileName: 'book.epub', row: makeRow(), status: 'ready', destPath: '/library/book.epub', library: { id: 5 }, folder: { id: 9, path: '/library' }, format: 'epub', }; mockAccess.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' })); const classified = await (service as any).classifyDestination(analysis, new Map()); expect(classified).toMatchObject({ status: 'error', message: 'Filing this book failed inside BookOrbit. Check the server log for the cause.', }); expect(warn).toHaveBeenCalledExactlyOnceWith( '[book_dock.finalize] [fail] fileId=1 stage=destination_check errorClass=Error errorCode=EACCES error="EACCES" - Book Dock file finalization failed', ); expect(storage.moveToPath).not.toHaveBeenCalled(); }); it('resolveDestination builds names from patterns and falls back per organization mode', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPattern.mockResolvedValue(null); appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null); const rowWithMeta = makeRow({ fileName: 'original.epub', selectedMetadata: { title: 'Dune', seriesIndex: '2.5' } as BookDockMetadata, }); await expect((service as any).resolveDestination({ fileNamingPattern: '{title}-{seriesIndex}' }, '/library', rowWithMeta, 'epub')).resolves.toBe( '/library/Dune-02.5.epub', ); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_folder' }, '/library', rowWithMeta, 'epub'), ).resolves.toBe('/library/original/original.epub'); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', rowWithMeta, 'epub'), ).resolves.toBe('/library/original.epub'); }); it('resolveDestination files legacy BookDock metadata with a numeric series index', async () => { const { service } = makeService(); const row = makeRow({ fileName: 'Day, Sylvia - Crossfire 03 - Entwined With You - Day, Sylvia.epub', selectedMetadata: { title: 'Entwined With You', authors: ['Sylvia Day'], seriesName: 'Crossfire', seriesIndex: 3, } as unknown as BookDockMetadata, }); await expect( (service as any).resolveDestination( { fileNamingPattern: '<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. >{title}' }, '/library', row, 'epub', ), ).resolves.toBe('/library/Sylvia Day/Crossfire/03. Entwined With You.epub'); }); it('resolveDestination uses folder-mode global pattern for book_per_folder libraries', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{title}/'); const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'Foundation' } as BookDockMetadata }); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_folder' }, '/library', row, 'epub'), ).resolves.toBe('/library/Foundation/book.epub'); expect(appSettings.getUploadPatternBookPerFolder).toHaveBeenCalled(); expect(appSettings.getUploadPattern).not.toHaveBeenCalled(); }); it('resolveDestination uses file-mode global pattern for book_per_file libraries', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPattern.mockResolvedValue('{title}.{extension}'); const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'Foundation' } as BookDockMetadata }); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', row, 'epub'), ).resolves.toBe('/library/Foundation.epub'); expect(appSettings.getUploadPattern).toHaveBeenCalled(); expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled(); }); it('resolveDestination keeps the folder segments a book_per_file pattern defines', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{series}/{title}'); const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'Foundation', authors: ['Isaac Asimov'], seriesName: 'Foundation' } as BookDockMetadata, }); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', row, 'epub'), ).resolves.toBe('/library/Isaac Asimov/Foundation/Foundation.epub'); }); it('resolveDestination files a book_per_file library under the shipped default pattern', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE); const row = makeRow({ fileName: 'book.epub', selectedMetadata: defaultPatternMetadata() }); await expect( (service as any).resolveDestination({ fileNamingPattern: null, organizationMode: 'book_per_file' }, '/library', row, 'epub'), ).resolves.toBe('/library/Frank Herbert/Dune/01. Dune (1965).epub'); }); it('resolveDestination library pattern wins over mode-specific global pattern', async () => { const { service, appSettings } = makeService(); appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{authors:first}/{title}/'); const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'Dune' } as BookDockMetadata }); await expect( (service as any).resolveDestination({ fileNamingPattern: '{title}.{extension}', organizationMode: 'book_per_folder' }, '/library', row, 'epub'), ).resolves.toBe('/library/Dune.epub'); expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled(); expect(appSettings.getUploadPattern).not.toHaveBeenCalled(); }); it('resolveDestination sanitizes token-derived names when cross-platform mode is enabled', async () => { const { service, appSettings } = makeService(); appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true); const row = makeRow({ fileName: 'book.epub', selectedMetadata: { title: 'AUX', authors: ['CON'] } as BookDockMetadata }); await expect((service as any).resolveDestination({ fileNamingPattern: '{authors:first}/{title}' }, '/library', row, 'epub')).resolves.toBe( '/library/CON_/AUX_.epub', ); }); it('triggerAutoFinalize skips when auto-finalize is disabled or destination is incomplete', async () => { const { service, appSettings, repo } = makeService(); appSettings.getAutoFinalizeSettings.mockResolvedValueOnce({ enabled: false, threshold: 80, libraryId: 1, folderId: 2, metadataMode: 'safe_merge', }); appSettings.getAutoFinalizeSettings.mockResolvedValueOnce({ enabled: true, threshold: 80, libraryId: null, folderId: 2, metadataMode: 'safe_merge', }); await service.triggerAutoFinalize(1); await service.triggerAutoFinalize(1); expect(repo.findById).not.toHaveBeenCalled(); }); it('onModuleInit subscribes to ingestion events and triggers auto-finalize callback', async () => { const { service, events } = makeService(); const triggerSpy = vi.spyOn(service, 'triggerAutoFinalize').mockResolvedValue(undefined); service.onModuleInit(); const handler = events.on.mock.calls[0]?.[1] as ((fileId: number) => void) | undefined; expect(handler).toBeDefined(); handler?.(77); await (service as any).autoFinalizeQueue.waitForIdle(); expect(triggerSpy).toHaveBeenCalledWith(77); }); });