diff --git a/server/src/modules/book-dock/book-dock-finalize.service.test.ts b/server/src/modules/book-dock/book-dock-finalize.service.test.ts index 83208ab86..b242827c7 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.test.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.test.ts @@ -62,6 +62,7 @@ function makeService() { 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), @@ -735,6 +736,20 @@ describe('BookDockFinalizeService', () => { 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'); @@ -2387,6 +2402,113 @@ describe('BookDockFinalizeService', () => { 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(); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index 6c5710f5e..ff65b7e4b 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -441,6 +441,8 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots throw err; } + // After the metadata, whose duration it supersedes, and before any write-back touches the files. + await this.measureAudioDurations(written.bookIds, row.id); this.processor.reconcileCoversAsync(written.bookIds); await this.cleanupBookDockRecord(row); if (library.fileWriteEnabled) { @@ -777,6 +779,29 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots .join(sep); } + /** + * The audiobook player derives progress from each audio file's own duration, and nothing else + * measures a filed file before the library's next scan; without one, listening stays at 0%. + * Never throws: the book is already filed, and a scan repairs whatever this could not measure. + */ + private async measureAudioDurations(bookIds: number[], fileId: number): Promise { + for (const bookId of bookIds) { + const startedAt = Date.now(); + try { + const { measured, failed } = await this.metadataService.measureMissingAudioDurations(bookId); + if (failed > 0) { + this.logger.warn( + `[book_dock.measure_audio_durations] [end] fileId=${fileId} bookId=${bookId} durationMs=${Date.now() - startedAt} measured=${measured} failed=${failed} - some audio durations could not be measured`, + ); + } + } catch (err) { + this.logger.warn( + `[book_dock.measure_audio_durations] [fail] fileId=${fileId} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - audio duration measurement failed`, + ); + } + } + } + /** Undoes a part-placed unit. Never throws: it runs while another error is on its way up. */ private async undoMoves(moved: Array<{ from: string; to: string }>): Promise { for (const move of moved) { diff --git a/server/src/modules/metadata/metadata.service.test.ts b/server/src/modules/metadata/metadata.service.test.ts index 331962a80..c825fba38 100644 --- a/server/src/modules/metadata/metadata.service.test.ts +++ b/server/src/modules/metadata/metadata.service.test.ts @@ -1865,6 +1865,113 @@ describe('MetadataService', () => { expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 4321 }); }); + it('extractAudioFileDuration returns the duration it wrote, or null when the file cannot be read', async () => { + const { db } = makeDb(); + const service = makeService(db); + + mockParseAudioDuration.mockResolvedValueOnce(null).mockResolvedValueOnce(4321); + + await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBeNull(); + await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBe(4321); + }); + + describe('measureMissingAudioDurations', () => { + function makeDurationDb(missing: Array<{ absolutePath: string }>) { + const selectWhere = vi.fn().mockResolvedValue(missing); + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn().mockReturnValue({ where: updateWhere }); + const db = { + select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }), + update: vi.fn().mockReturnValue({ set: updateSet }), + }; + return { db, selectWhere, updateSet, updateWhere }; + } + + it('looks only at the audio content files of the book that have no positive duration', async () => { + const { db, selectWhere } = makeDurationDb([]); + const service = makeService(db); + + await service.measureMissingAudioDurations(42); + + const query = new PgDialect().sqlToQuery(selectWhere.mock.calls[0]![0]); + expect(query.params).toEqual([42, 'content', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac', 0]); + expect(query.sql).toMatch(/"duration_seconds" is null or "book_files"\."duration_seconds" <= \$9/); + }); + + it('measures each file, then re-totals the book from the durations it wrote', async () => { + const { db, updateSet, updateWhere } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]); + const service = makeService(db); + mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : 900)); + const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined); + + await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 2, failed: 0 }); + + expect(mockParseAudioDuration.mock.calls.map((call) => call[0]).sort()).toEqual(['/books/a/01.mp3', '/books/a/02.mp3']); + expect(updateSet.mock.calls.map((call) => call[0]).sort((a, b) => a.durationSeconds - b.durationSeconds)).toEqual([ + { durationSeconds: 600 }, + { durationSeconds: 900 }, + ]); + expect(aggregate).toHaveBeenCalledExactlyOnceWith(42); + expect(aggregate.mock.invocationCallOrder[0]!).toBeGreaterThan(Math.max(...updateWhere.mock.invocationCallOrder)); + }); + + it('leaves the total alone when a file cannot be measured, so the rest cannot shrink it', async () => { + const { db, updateSet } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]); + const service = makeService(db); + mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : null)); + const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined); + + await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 1, failed: 1 }); + + expect(updateSet).toHaveBeenCalledExactlyOnceWith({ durationSeconds: 600 }); + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('treats a duration that rounds to zero as unmeasured', async () => { + const { db } = makeDurationDb([{ absolutePath: '/books/a/blip.m4b' }]); + const service = makeService(db); + mockParseAudioDuration.mockResolvedValue(0); + const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined); + + await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 1 }); + + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('probes nothing and keeps the total for a book with no missing durations', async () => { + const { db } = makeDurationDb([]); + const service = makeService(db); + const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined); + + await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 0 }); + + expect(mockParseAudioDuration).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('probes at most four files at a time', async () => { + const tracks = Array.from({ length: 10 }, (_, index) => ({ absolutePath: `/books/a/${String(index + 1).padStart(2, '0')}.mp3` })); + const { db } = makeDurationDb(tracks); + const service = makeService(db); + let inFlight = 0; + let peak = 0; + mockParseAudioDuration.mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + return 60; + }); + vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined); + + await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 10, failed: 0 }); + + expect(peak).toBe(4); + expect(mockParseAudioDuration).toHaveBeenCalledTimes(10); + }); + }); + it('extractAndAggregateAudioDuration writes the per-file duration before re-aggregating the total', async () => { const { db } = makeDb(); const service = makeService(db); @@ -1872,7 +1979,7 @@ describe('MetadataService', () => { const callOrder: string[] = []; const extractSpy = vi.spyOn(service, 'extractAudioFileDuration').mockImplementation(() => { callOrder.push('extract'); - return Promise.resolve(); + return Promise.resolve(null); }); const aggregateSpy = vi.spyOn(service, 'aggregateAudioDuration').mockImplementation(() => { callOrder.push('aggregate'); diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index 616875f66..aaa4243c6 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -1,10 +1,11 @@ import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; -import { and, eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import sharp from 'sharp'; import { DB } from '../../db'; import * as schema from '../../db/schema'; +import { mapWithConcurrency } from '../../common/utils/batch.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { isKnownPlaceholderCover } from '../../common/utils/placeholder-cover.utils'; import { @@ -55,6 +56,7 @@ const MAX_PUBLISHED_YEAR = 2200; const MAX_COVER_DOWNLOADS = 3; const MAX_COVER_DOWNLOAD_BYTES = 20 * 1024 * 1024; const COVER_DOWNLOAD_TIMEOUT_MS = 15_000; +const AUDIO_DURATION_PROBE_CONCURRENCY = 4; /** A fetched cover and how the provider's stated shape fits the slot it is meant for. */ export type CoverDownloadChoice = { url: string; fit?: 'match' | 'unknown' | 'mismatch' }; @@ -441,13 +443,44 @@ export class MetadataService { return chaptersReachLastFile(stored, orderedDurationsMs); } - async extractAudioFileDuration(bookId: number, absolutePath: string): Promise { + async extractAudioFileDuration(bookId: number, absolutePath: string): Promise { const durationSeconds = await parseAudioDuration(absolutePath); - if (durationSeconds === null) return; + if (durationSeconds === null) return null; await this.db .update(schema.bookFiles) .set({ durationSeconds }) .where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.absolutePath, absolutePath))); + return durationSeconds; + } + + /** + * Measures the audio files of a book that have no duration yet, then re-totals the book. The + * audiobook player derives progress only once every file has a duration, so a book left with a + * gap stays at 0%. A file that cannot be measured leaves the total alone: summing only the rest + * would shrink it. + */ + async measureMissingAudioDurations(bookId: number): Promise<{ measured: number; failed: number }> { + const files = await this.db + .select({ absolutePath: schema.bookFiles.absolutePath }) + .from(schema.bookFiles) + .where( + and( + eq(schema.bookFiles.bookId, bookId), + eq(schema.bookFiles.role, 'content'), + inArray(schema.bookFiles.format, [...METADATA_AUDIO_FORMATS]), + or(isNull(schema.bookFiles.durationSeconds), lte(schema.bookFiles.durationSeconds, 0)), + ), + ); + if (files.length === 0) return { measured: 0, failed: 0 }; + + const outcomes = await mapWithConcurrency(files, AUDIO_DURATION_PROBE_CONCURRENCY, async (file) => { + const durationSeconds = await this.extractAudioFileDuration(bookId, file.absolutePath); + return durationSeconds !== null && durationSeconds > 0; + }); + const measured = outcomes.filter(Boolean).length; + const failed = files.length - measured; + if (failed === 0) await this.aggregateAudioDuration(bookId); + return { measured, failed }; } async aggregateAudioDuration(bookId: number): Promise { diff --git a/server/test/book-dock-ingest-finalize.e2e-spec.ts b/server/test/book-dock-ingest-finalize.e2e-spec.ts index 777c91ca7..c781e3466 100644 --- a/server/test/book-dock-ingest-finalize.e2e-spec.ts +++ b/server/test/book-dock-ingest-finalize.e2e-spec.ts @@ -1,3 +1,10 @@ +// The audio extractor reads FFPROBE_PATH as it loads, so this has to run ahead of every import below. +vi.hoisted(() => { + const requireFromHere = process.getBuiltinModule('node:module').createRequire(__filename); + process.env.FFPROBE_PATH ??= (requireFromHere('@ffprobe-installer/ffprobe') as { path: string }).path; +}); + +import { randomUUID } from 'crypto'; import { mkdir, readFile, realpath, utimes, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { afterEach } from 'vitest'; @@ -5,10 +12,13 @@ import { afterEach } from 'vitest'; import { eq, inArray } from 'drizzle-orm'; import { Permission, + type AudiobookManifest, + type AudiobookPlaybackState, type BookDockDiscardDuplicatesResult, type BookDockFile, type BookDockFinalizePreviewResult, type BookDockFinalizeResult, + type DashboardScrollerResponse, } from '@bookorbit/types'; import * as schema from '../src/db/schema'; @@ -16,7 +26,7 @@ import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser'; import { ScannerService } from '../src/modules/scanner/scanner.service'; import { waitForCondition } from './e2e/app-harness'; import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder'; -import { createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder'; +import { createM4bFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder'; import { authHeader, closeBookDockE2EContext, @@ -528,6 +538,125 @@ describe('Book Dock ingest + finalize (e2e)', () => { }); }); + /** + * Issue 1757: finalize filed audiobooks with no per-file duration, and the player derives progress + * only from those, so listening stayed at 0% and the book never reached Continue Listening until + * the library was next scanned. These libraries are not watched, so nothing else measures them. + */ + describe('listening to a filed audiobook', () => { + const AUDIO_SECONDS = 4; + const metadata = { title: 'Listening Title', authors: ['Listening Author'] }; + + async function finalizeRow(row: { id: number }): Promise { + const response = await context.app.inject({ + method: 'POST', + url: '/api/v1/book-dock/finalize', + headers: authHeader(context.adminToken), + payload: { fileIds: [row.id] }, + }); + expect(response.statusCode).toBe(201); + const result = (response.json() as BookDockFinalizeResult).results[0]; + expect(result).toMatchObject({ success: true, bookId: expect.any(Number) }); + return result!.bookId!; + } + + async function dockAudiobook(destination: Awaited>, fileName: string, selected = {}) { + const fixture = await createM4bFixture(context.fixture.booksPath, `audio-fixtures/${randomUUID()}.m4b`, { + title: metadata.title, + durationSeconds: AUDIO_SECONDS, + }); + return createBookDockRow(context, { + fileName, + content: await readFile(fixture), + selectedMetadata: { ...metadata, ...selected }, + targetLibraryId: destination.libraryId, + targetFolderId: destination.libraryFolderId, + }); + } + + async function audioDurations(bookId: number) { + const files = await context.db + .select({ format: schema.bookFiles.format, durationSeconds: schema.bookFiles.durationSeconds }) + .from(schema.bookFiles) + .where(eq(schema.bookFiles.bookId, bookId)); + const [meta] = await context.db + .select({ durationSeconds: schema.bookMetadata.durationSeconds }) + .from(schema.bookMetadata) + .where(eq(schema.bookMetadata.bookId, bookId)) + .limit(1); + return { files: files.filter((file) => file.format === 'm4b').map((file) => file.durationSeconds), book: meta?.durationSeconds ?? null }; + } + + /** What the web player and the iOS app do: read the manifest, then save a position halfway in. */ + async function listenHalfway(bookId: number): Promise { + const manifestResponse = await context.app.inject({ + method: 'GET', + url: `/api/v1/audiobooks/${bookId}/manifest`, + headers: authHeader(context.adminToken), + }); + expect(manifestResponse.statusCode).toBe(200); + const manifest = manifestResponse.json() as AudiobookManifest; + expect(manifest.assets).toHaveLength(1); + expect(manifest.assets[0]!.durationMs).toBe(AUDIO_SECONDS * 1000); + expect(manifest.totalDurationMs).toBe(AUDIO_SECONDS * 1000); + + const saveResponse = await context.app.inject({ + method: 'PUT', + url: `/api/v1/audiobooks/${bookId}/playback-state`, + headers: authHeader(context.adminToken), + payload: { + assetId: manifest.assets[0]!.assetId, + positionMs: (AUDIO_SECONDS * 1000) / 2, + capturedAt: new Date().toISOString(), + operationId: randomUUID(), + baseRevision: 0, + manifestRevision: manifest.revision, + }, + }); + expect(saveResponse.statusCode).toBe(200); + return saveResponse.json() as AudiobookPlaybackState; + } + + async function continueListeningIds(): Promise { + const response = await context.app.inject({ + method: 'GET', + url: '/api/v1/dashboard/scrollers/continue-listening?limit=50', + headers: authHeader(context.adminToken), + }); + expect(response.statusCode).toBe(200); + return (response.json() as DashboardScrollerResponse).books.map((book) => book.id); + } + + it('measures a new audiobook, so listening moves its progress and puts it on Continue Listening', async () => { + const destination = await createLibraryWithFolder(context, { allowedFormats: ['m4b'] }); + // A runtime from a metadata provider: the file's own length replaces it, as a scan would. + const row = await dockAudiobook(destination, 'listening-new.m4b', { durationSeconds: 3600 }); + + const bookId = await finalizeRow(row); + + expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS }); + expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false }); + expect(await continueListeningIds()).toContain(bookId); + }); + + it('measures an audiobook that joins an ebook already on the shelf', async () => { + const destination = await createLibraryWithFolder(context, { allowedFormats: ['epub', 'm4b'] }); + const ebook = await createBookDockRow(context, { + fileName: 'listening-joined.epub', + selectedMetadata: metadata, + targetLibraryId: destination.libraryId, + targetFolderId: destination.libraryFolderId, + }); + const bookId = await finalizeRow(ebook); + + expect(await finalizeRow(await dockAudiobook(destination, 'listening-joined.m4b'))).toBe(bookId); + + expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS }); + expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false }); + expect(await continueListeningIds()).toContain(bookId); + }); + }); + it('finalize writes the dock metadata into the file when the library writes metadata to files', async () => { const destination = await createLibraryWithFolder(context, { fileWriteEnabled: true, diff --git a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts index 6062a18f4..054db9ef4 100644 --- a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts +++ b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts @@ -59,7 +59,7 @@ export function bundledFfmpegPaths(): { ffmpeg: string; ffprobe: string } { return { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path }; } -export async function createM4bFixture(rootPath: string, relativePath: string, input: { title: string }): Promise { +export async function createM4bFixture(rootPath: string, relativePath: string, input: { title: string; durationSeconds?: number }): Promise { assertRelativePath(relativePath); const absolutePath = join(rootPath, relativePath); await mkdir(dirname(absolutePath), { recursive: true }); @@ -70,7 +70,7 @@ export async function createM4bFixture(rootPath: string, relativePath: string, i '-f', 'lavfi', '-i', - 'sine=frequency=440:sample_rate=8000:duration=1', + `sine=frequency=440:sample_rate=8000:duration=${input.durationSeconds ?? 1}`, '-c:a', 'aac', '-b:a',