diff --git a/server/src/modules/metadata/extractors/audio-chapter-merge.test.ts b/server/src/modules/metadata/extractors/audio-chapter-merge.test.ts new file mode 100644 index 000000000..0b2ab98f2 --- /dev/null +++ b/server/src/modules/metadata/extractors/audio-chapter-merge.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import { chaptersReachLastFile, mergeAudioChapters, type AudioChapterSource } from './audio-chapter-merge'; + +function source(overrides: Partial & Pick): AudioChapterSource { + return { chapters: [], durationMs: null, ...overrides }; +} + +describe('mergeAudioChapters', () => { + it('offsets each file by the combined length of the files before it', () => { + const merged = mergeAudioChapters([ + source({ + absolutePath: '/books/Red Rising/Red Rising - 01.m4b', + durationMs: 360_000, + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + { title: 'Chapter 3', startMs: 240_000 }, + ], + }), + source({ + absolutePath: '/books/Red Rising/Red Rising - 02.m4b', + durationMs: 240_000, + chapters: [ + { title: 'Chapter 4', startMs: 0 }, + { title: 'Chapter 5', startMs: 120_000 }, + ], + }), + ]); + + expect(merged).toEqual([ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + { title: 'Chapter 3', startMs: 240_000 }, + { title: 'Chapter 4', startMs: 360_000 }, + { title: 'Chapter 5', startMs: 480_000 }, + ]); + }); + + it('ends the first file last chapter where the second file starts', () => { + const totalMs = 360_000 + 240_000; + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/01.m4b', durationMs: 360_000, chapters: [{ title: 'Last of part one', startMs: 240_000 }] }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: 240_000, chapters: [{ title: 'First of part two', startMs: 0 }] }), + ])!; + + const lastOfPartOne = merged[0]!; + const firstOfPartTwo = merged[1]!; + + // Without the merge this chapter would run to the end of the whole book. + expect(firstOfPartTwo.startMs - lastOfPartOne.startMs).toBe(120_000); + expect(totalMs - lastOfPartOne.startMs).toBe(360_000); + }); + + it('accumulates offsets across more than two files', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/01.mp3', durationMs: 100_000, chapters: [{ title: 'One', startMs: 0 }] }), + source({ absolutePath: '/books/Book/02.mp3', durationMs: 200_000, chapters: [{ title: 'Two', startMs: 50_000 }] }), + source({ absolutePath: '/books/Book/03.mp3', durationMs: 300_000, chapters: [{ title: 'Three', startMs: 10_000 }] }), + ]); + + expect(merged).toEqual([ + { title: 'One', startMs: 0 }, + { title: 'Two', startMs: 150_000 }, + { title: 'Three', startMs: 310_000 }, + ]); + }); + + it('gives a chapterless file its own chapter so the previous file does not swallow it', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/Part 01.m4b', durationMs: 60_000, chapters: [{ title: 'Opening', startMs: 0 }] }), + source({ absolutePath: '/books/Book/Part 02.m4b', durationMs: 90_000 }), + source({ absolutePath: '/books/Book/Part 03.m4b', durationMs: 30_000, chapters: [{ title: 'Closing', startMs: 0 }] }), + ]); + + expect(merged).toEqual([ + { title: 'Opening', startMs: 0 }, + { title: 'Part 02', startMs: 60_000 }, + { title: 'Closing', startMs: 150_000 }, + ]); + }); + + it('returns nothing to store when no file carries chapters', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/01.mp3', durationMs: 60_000 }), + source({ absolutePath: '/books/Book/02.mp3', durationMs: 60_000 }), + ]); + + expect(merged).toEqual([]); + }); + + it('returns nothing to store for an empty file list', () => { + expect(mergeAudioChapters([])).toEqual([]); + }); + + it('refuses to merge when a file that others follow has no readable length', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/01.m4b', durationMs: null, chapters: [{ title: 'One', startMs: 0 }] }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: 240_000, chapters: [{ title: 'Two', startMs: 0 }] }), + ]); + + expect(merged).toBeNull(); + }); + + it('merges when only the last file has no readable length, since nothing follows it', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/01.m4b', durationMs: 360_000, chapters: [{ title: 'One', startMs: 0 }] }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: null, chapters: [{ title: 'Two', startMs: 0 }] }), + ]); + + expect(merged).toEqual([ + { title: 'One', startMs: 0 }, + { title: 'Two', startMs: 360_000 }, + ]); + }); + + it('orders chapters within a file before offsetting them', () => { + const merged = mergeAudioChapters([ + source({ + absolutePath: '/books/Book/01.m4b', + durationMs: 100_000, + chapters: [ + { title: 'Second', startMs: 50_000 }, + { title: 'First', startMs: 0 }, + ], + }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: 100_000, chapters: [{ title: 'Third', startMs: 0 }] }), + ]); + + expect(merged).toEqual([ + { title: 'First', startMs: 0 }, + { title: 'Second', startMs: 50_000 }, + { title: 'Third', startMs: 100_000 }, + ]); + }); + + it('drops chapters that start before the file does', () => { + const merged = mergeAudioChapters([ + source({ + absolutePath: '/books/Book/01.m4b', + durationMs: 100_000, + chapters: [ + { title: 'Bogus', startMs: -5_000 }, + { title: 'Real', startMs: 0 }, + ], + }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: 100_000, chapters: [{ title: 'Next', startMs: 0 }] }), + ]); + + expect(merged).toEqual([ + { title: 'Real', startMs: 0 }, + { title: 'Next', startMs: 100_000 }, + ]); + }); + + it('names a chapterless file after the file itself when every chapter of it was dropped', () => { + const merged = mergeAudioChapters([ + source({ absolutePath: '/books/Book/Part 01.m4b', durationMs: 100_000, chapters: [{ title: 'Bogus', startMs: -1 }] }), + source({ absolutePath: '/books/Book/Part 02.m4b', durationMs: 100_000, chapters: [{ title: 'Real', startMs: 0 }] }), + ]); + + expect(merged).toEqual([ + { title: 'Part 01', startMs: 0 }, + { title: 'Real', startMs: 100_000 }, + ]); + }); + + it('leaves a single file unchanged', () => { + const merged = mergeAudioChapters([ + source({ + absolutePath: '/books/Book/book.m4b', + durationMs: 3_600_000, + chapters: [ + { title: 'One', startMs: 0 }, + { title: 'Two', startMs: 60_000 }, + ], + }), + ]); + + expect(merged).toEqual([ + { title: 'One', startMs: 0 }, + { title: 'Two', startMs: 60_000 }, + ]); + }); + + it('keeps merged chapters in ascending order', () => { + const merged = mergeAudioChapters([ + source({ + absolutePath: '/books/Book/01.m4b', + durationMs: 200_000, + chapters: [ + { title: 'a', startMs: 0 }, + { title: 'b', startMs: 199_999 }, + ], + }), + source({ absolutePath: '/books/Book/02.m4b', durationMs: 200_000, chapters: [{ title: 'c', startMs: 1 }] }), + ])!; + + const startTimes = merged.map((chapter) => chapter.startMs); + expect(startTimes).toEqual([...startTimes].sort((a, b) => a - b)); + }); +}); + +describe('chaptersReachLastFile', () => { + it('reports a list extracted from the first file alone as short', () => { + const partOneChapters = [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + { title: 'Chapter 3', startMs: 240_000 }, + ]; + + expect(chaptersReachLastFile(partOneChapters, [360_000, 240_000])).toBe(false); + }); + + it('accepts a merged list that reaches into the last file', () => { + const merged = [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 4', startMs: 360_000 }, + ]; + + expect(chaptersReachLastFile(merged, [360_000, 240_000])).toBe(true); + }); + + it('accepts a merged list whose last chapter sits a rounded second before the last file', () => { + // Per-file lengths round to whole seconds, so a merge writes offsets slightly off the durations + // this check can see. Without slack the same book would be rebuilt on every scan. + const merged = [{ title: 'Part two', startMs: 359_500 }]; + + expect(chaptersReachLastFile(merged, [360_000, 240_000])).toBe(true); + }); + + it('keeps its slack proportional to the number of files before the last one', () => { + const merged = [{ title: 'Part four', startMs: 297_000 }]; + + expect(chaptersReachLastFile(merged, [100_000, 100_000, 100_000, 100_000])).toBe(true); + expect(chaptersReachLastFile([{ title: 'Part two', startMs: 100_000 }], [100_000, 100_000, 100_000, 100_000])).toBe(false); + }); + + it('says yes for a single file, which has nothing to fall short of', () => { + expect(chaptersReachLastFile([{ title: 'Chapter 1', startMs: 0 }], [3_600_000])).toBe(true); + }); + + it('says yes for an empty list, which the per-file fallback owns', () => { + expect(chaptersReachLastFile([], [360_000, 240_000])).toBe(true); + }); + + it('says yes when a length before the last file is unknown', () => { + expect(chaptersReachLastFile([{ title: 'Chapter 1', startMs: 0 }], [null, 240_000])).toBe(true); + }); + + it('ignores an unknown length on the last file, which no offset depends on', () => { + expect(chaptersReachLastFile([{ title: 'Chapter 1', startMs: 0 }], [360_000, null])).toBe(false); + }); +}); diff --git a/server/src/modules/metadata/extractors/audio-chapter-merge.ts b/server/src/modules/metadata/extractors/audio-chapter-merge.ts new file mode 100644 index 000000000..c51312950 --- /dev/null +++ b/server/src/modules/metadata/extractors/audio-chapter-merge.ts @@ -0,0 +1,79 @@ +import { basename } from 'path'; + +import type { AudiobookChapter } from '@bookorbit/types'; + +export interface AudioChapterSource { + absolutePath: string; + chapters: AudiobookChapter[]; + durationMs: number | null; +} + +// Per-file lengths are stored rounded to whole seconds, so offsets derived from them drift from the +// exact millisecond offsets a merge writes. A second of slack per file keeps a freshly merged list +// from reading as short and being rebuilt on every scan. +const COVERAGE_SLACK_MS_PER_FILE = 1000; + +/** + * Builds one chapter list for an audiobook split across several files. + * + * Each file embeds its own chapters starting at zero, but playback is the files concatenated in + * natural order, so every file's chapters shift by the combined length of the files before it. + * Sources must already be in playback order. + * + * Returns an empty list when no file carries chapters, leaving the per-file fallback in charge, and + * null when a length is missing for a file that others follow, because every later offset would be + * wrong; callers keep the stored chapters instead. + */ +export function mergeAudioChapters(sources: AudioChapterSource[]): AudiobookChapter[] | null { + if (sources.every((source) => source.chapters.length === 0)) return []; + + const merged: AudiobookChapter[] = []; + let offsetMs = 0; + + for (const [index, source] of sources.entries()) { + const isLast = index === sources.length - 1; + if (source.durationMs === null && !isLast) return null; + + const ordered = source.chapters.filter((chapter) => chapter.startMs >= 0).sort((a, b) => a.startMs - b.startMs); + + if (ordered.length > 0) { + for (const chapter of ordered) { + merged.push({ title: chapter.title, startMs: offsetMs + chapter.startMs }); + } + } else { + // A file with no chapters of its own still gets one, so the previous file's last chapter ends + // where this file starts instead of swallowing it. + merged.push({ title: fileStem(source.absolutePath), startMs: offsetMs }); + } + + offsetMs += source.durationMs ?? 0; + } + + return merged; +} + +/** + * Answers whether a stored chapter list describes the whole of a multi-file audiobook. + * + * A list extracted from a single file stops at that file, leaving the rest of the book with no + * chapters of its own. Catching that is what lets a book scanned before chapters were merged be + * rebuilt once, without re-probing every audiobook on every scan. + * + * Durations must be in playback order. Says yes whenever it cannot tell: an empty list belongs to + * the per-file fallback, and an unknown length makes the last file's start unknowable. + */ +export function chaptersReachLastFile(chapters: AudiobookChapter[], orderedDurationsMs: (number | null)[]): boolean { + if (orderedDurationsMs.length < 2) return true; + if (chapters.length === 0) return true; + + const leadingDurations = orderedDurationsMs.slice(0, -1); + if (leadingDurations.some((duration) => duration === null)) return true; + + const lastFileStartMs = leadingDurations.reduce((total: number, duration) => total + duration!, 0); + const slackMs = COVERAGE_SLACK_MS_PER_FILE * leadingDurations.length; + return chapters.some((chapter) => chapter.startMs >= lastFileStartMs - slackMs); +} + +function fileStem(absolutePath: string): string { + return basename(absolutePath).replace(/\.[^.]+$/, ''); +} diff --git a/server/src/modules/metadata/extractors/audio.extractor.test.ts b/server/src/modules/metadata/extractors/audio.extractor.test.ts index 9a8140aa8..e3ce0a006 100644 --- a/server/src/modules/metadata/extractors/audio.extractor.test.ts +++ b/server/src/modules/metadata/extractors/audio.extractor.test.ts @@ -6,7 +6,7 @@ vi.mock('child_process', async (importOriginal) => { }); import { execFile as execFileCallback, spawn } from 'child_process'; -import { extractAudioMetadata, parseAudioDuration } from './audio.extractor'; +import { extractAudioMetadata, parseAudioDuration, probeAudioChapters } from './audio.extractor'; import { EventEmitter } from 'events'; const mockExecFile = execFileCallback as unknown as Mock; @@ -709,6 +709,103 @@ describe('parseAudioDuration', () => { }); }); +// ── probeAudioChapters ──────────────────────────────────────────────────────── + +describe('probeAudioChapters', () => { + beforeEach(() => resetMocks()); + + it('returns the embedded chapters and the file length in milliseconds', async () => { + makeExecFileSuccess( + JSON.stringify({ + format: { duration: '360.5' }, + chapters: [ + { start_time: '0.000000', tags: { title: 'Chapter 1' } }, + { start_time: '120.000000', tags: { title: 'Chapter 2' } }, + ], + }), + ); + + const result = await probeAudioChapters('/books/Book/01.m4b'); + + expect(result).toEqual({ + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + ], + durationMs: 360_500, + }); + }); + + it('keeps sub-second precision that the per-file duration rounds away', async () => { + makeExecFileSuccess(JSON.stringify({ format: { duration: '119.994000' }, chapters: [] })); + + const result = await probeAudioChapters('/books/Book/01.m4b'); + + expect(result.durationMs).toBe(119_994); + }); + + it('returns an empty chapter list for a file with no embedded chapters', async () => { + makeExecFileSuccess(JSON.stringify({ format: { duration: '60' }, chapters: [] })); + + const result = await probeAudioChapters('/books/Book/02.mp3'); + + expect(result).toEqual({ chapters: [], durationMs: 60_000 }); + }); + + it('reports an unknown length rather than guessing when duration is missing', async () => { + makeExecFileSuccess(JSON.stringify({ format: {}, chapters: [{ start_time: '0.000000', tags: { title: 'Only' } }] })); + + const result = await probeAudioChapters('/books/Book/03.m4b'); + + expect(result).toEqual({ chapters: [{ title: 'Only', startMs: 0 }], durationMs: null }); + }); + + it('titles a chapter with an empty string when the file does not name it', async () => { + makeExecFileSuccess(JSON.stringify({ format: { duration: '10' }, chapters: [{ start_time: '0.000000' }] })); + + const result = await probeAudioChapters('/books/Book/04.m4b'); + + expect(result.chapters).toEqual([{ title: '', startMs: 0 }]); + }); + + it('drops a chapter whose start time is not numeric', async () => { + makeExecFileSuccess( + JSON.stringify({ + format: { duration: '10' }, + chapters: [ + { start_time: 'N/A', tags: { title: 'Broken' } }, + { start_time: '5.000000', tags: { title: 'Fine' } }, + ], + }), + ); + + const result = await probeAudioChapters('/books/Book/05.m4b'); + + expect(result.chapters).toEqual([{ title: 'Fine', startMs: 5_000 }]); + }); + + it('returns empty results when ffprobe throws', async () => { + makeExecFileError('read error'); + + const result = await probeAudioChapters('/books/Book/missing.m4b'); + + expect(result).toEqual({ chapters: [], durationMs: null }); + }); + + it('asks ffprobe for chapters and format only, and never extracts a cover', async () => { + makeExecFileSuccess(JSON.stringify({ format: { duration: '10' }, chapters: [] })); + + await probeAudioChapters('/books/Book/01.m4b'); + + expect(mockExecFile).toHaveBeenCalledWith( + 'ffprobe', + ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_chapters', '/books/Book/01.m4b'], + expect.any(Function), + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); +}); + // ── DESCRIPTION TAG FALLBACK ────────────────────────────────────────────────── describe('extractAudioMetadata — description tag fallback', () => { diff --git a/server/src/modules/metadata/extractors/audio.extractor.ts b/server/src/modules/metadata/extractors/audio.extractor.ts index e7c9ac2a6..8708340a5 100644 --- a/server/src/modules/metadata/extractors/audio.extractor.ts +++ b/server/src/modules/metadata/extractors/audio.extractor.ts @@ -28,6 +28,11 @@ export interface AudioExtractResult { coverBytes: Buffer | null; } +export interface AudioChapterProbe { + chapters: AudiobookChapter[]; + durationMs: number | null; +} + interface FfprobeStream { codec_type: string; codec_name: string; @@ -106,11 +111,7 @@ export async function extractAudioMetadata(absolutePath: string): Promise { - const startMs = parseChapterStartMs(ch.start_time); - if (startMs === null) return []; - return [{ title: ch.tags?.title ?? '', startMs }]; - }); + const mappedChapters = mapChapters(chapters); const coverBytes = await extractCoverBytes(absolutePath, streams); @@ -148,6 +149,26 @@ export async function parseAudioDuration(absolutePath: string): Promise { + try { + const { stdout } = await execFile(FFPROBE_PATH, ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_chapters', absolutePath]); + const data: FfprobeOutput = JSON.parse(stdout); + return { chapters: mapChapters(data.chapters ?? []), durationMs: parseDurationMs(data.format?.duration) }; + } catch { + return { chapters: [], durationMs: null }; + } +} + +function mapChapters(chapters: FfprobeChapter[]): AudiobookChapter[] { + return chapters.flatMap((ch) => { + const startMs = parseChapterStartMs(ch.start_time); + if (startMs === null) return []; + return [{ title: ch.tags?.title ?? '', startMs }]; + }); +} + async function extractCoverBytes(absolutePath: string, streams: FfprobeStream[]): Promise { const hasEmbeddedImage = streams.some((s) => s.codec_type === 'video'); if (!hasEmbeddedImage) return null; @@ -219,6 +240,14 @@ function parseDurationSeconds(raw: string | undefined): number | null { return Number.isFinite(parsed) ? Math.round(parsed) : null; } +// Milliseconds, not the rounded seconds stored per file: chapter offsets accumulate across files, +// so half-second rounding errors would compound down the book. +function parseDurationMs(raw: string | undefined): number | null { + if (!raw) return null; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? Math.round(parsed * 1000) : null; +} + function parseChapterStartMs(raw: string): number | null { const parsed = Number.parseFloat(raw); return Number.isFinite(parsed) ? Math.round(parsed * 1000) : null; diff --git a/server/src/modules/metadata/metadata.service.test.ts b/server/src/modules/metadata/metadata.service.test.ts index 12dc22ef6..dca0a632d 100644 --- a/server/src/modules/metadata/metadata.service.test.ts +++ b/server/src/modules/metadata/metadata.service.test.ts @@ -81,6 +81,7 @@ vi.mock('./extractors/audio.extractor', () => ({ }), ), parseAudioDuration: vi.fn().mockImplementation(() => Promise.resolve(null)), + probeAudioChapters: vi.fn().mockImplementation(() => Promise.resolve({ chapters: [], durationMs: null })), })); import { mkdir, readFile, readdir, rm, writeFile } from 'fs/promises'; @@ -95,7 +96,7 @@ import { extractEpubMetadata } from './lib/epub'; import { parseBookFilename } from './lib/filename-parser'; import { parseMobiFile } from './lib/mobi-parser'; import { parsePdfFile } from './lib/pdf-parser'; -import { extractAudioMetadata, parseAudioDuration } from './extractors/audio.extractor'; +import { extractAudioMetadata, parseAudioDuration, probeAudioChapters } from './extractors/audio.extractor'; import { METADATA_AUTHORS_REPLACED } from './metadata-events.service'; import { MetadataService } from './metadata.service'; import { MetadataExtractionService } from './metadata-extraction.service'; @@ -116,6 +117,7 @@ const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction; const mockParseAudioDuration = parseAudioDuration as MockedFunction; +const mockProbeAudioChapters = probeAudioChapters as MockedFunction; const makeDb = () => { const updateWhere = vi.fn().mockResolvedValue(undefined); @@ -203,6 +205,7 @@ describe('MetadataService', () => { coverBytes: null, }); mockParseAudioDuration.mockResolvedValue(null); + mockProbeAudioChapters.mockResolvedValue({ chapters: [], durationMs: null }); }); function makeService( @@ -1297,6 +1300,267 @@ describe('MetadataService', () => { await expect(service.extractAudioChaptersAndNarrators(72, '/tmp/book.pdf', 'pdf')).resolves.toBeUndefined(); }); + describe('extractMergedAudioChapters', () => { + const partOne = '/books/Red Rising/Red Rising - 01.m4b'; + const partTwo = '/books/Red Rising/Red Rising - 02.m4b'; + + function makeLockService() { + return { + isFieldLocked: vi.fn().mockResolvedValue(false), + filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })), + }; + } + + function probeReturns(probes: Record) { + mockProbeAudioChapters.mockImplementation((absolutePath: string) => + Promise.resolve(probes[absolutePath] ?? { chapters: [], durationMs: null }), + ); + } + + it('stores one chapter list covering every file, offset into the combined timeline', async () => { + const { db, updateSet } = makeDb(); + const lockService = makeLockService(); + const service = makeService(db, undefined, { bookMetadataLockService: lockService }); + + probeReturns({ + [partOne]: { + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + ], + durationMs: 360_000, + }, + [partTwo]: { chapters: [{ title: 'Chapter 3', startMs: 0 }], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true }); + + expect(mockProbeAudioChapters).toHaveBeenCalledTimes(2); + expect(mockProbeAudioChapters).toHaveBeenNthCalledWith(1, partOne); + expect(mockProbeAudioChapters).toHaveBeenNthCalledWith(2, partTwo); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 120_000 }, + { title: 'Chapter 3', startMs: 360_000 }, + ], + updatedAt: expect.any(Date), + }), + ); + }); + + it('probes files in the order it is given, since offsets depend on playback order', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + + probeReturns({ + [partOne]: { chapters: [{ title: 'First', startMs: 0 }], durationMs: 100_000 }, + [partTwo]: { chapters: [{ title: 'Second', startMs: 0 }], durationMs: 100_000 }, + }); + + await service.extractMergedAudioChapters(42, [partTwo, partOne], { filesChanged: true }); + + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + chapters: [ + { title: 'Second', startMs: 0 }, + { title: 'First', startMs: 100_000 }, + ], + }), + ); + }); + + it('does nothing for a single-file audiobook, which extraction already covers', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + + await service.extractMergedAudioChapters(42, [partOne], { filesChanged: true }); + + expect(mockProbeAudioChapters).not.toHaveBeenCalled(); + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('leaves stored chapters alone when a file length cannot be read', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + + probeReturns({ + [partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: null }, + [partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true }); + + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('writes nothing when no file carries embedded chapters', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + + probeReturns({ + [partOne]: { chapters: [], durationMs: 360_000 }, + [partTwo]: { chapters: [], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true }); + + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('passes the merged chapters through the metadata lock filter', async () => { + const { db, updateSet } = makeDb(); + const lockService = makeLockService(); + const service = makeService(db, undefined, { bookMetadataLockService: lockService }); + + probeReturns({ + [partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: 360_000 }, + [partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true }); + + expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(42, { + audioMetadata: { + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 360_000 }, + ], + }, + }); + expect(updateSet).toHaveBeenCalled(); + }); + + describe('with no file changed', () => { + function withStoredState(db: any, chapters: unknown[], fileRows: { absolutePath: string; durationSeconds: number | null }[]) { + let call = 0; + db.select.mockImplementation(() => ({ + from: () => ({ + where: () => { + call += 1; + if (call === 1) return { limit: () => Promise.resolve([{ chapters }]) }; + return Promise.resolve(fileRows); + }, + }), + })); + } + + it('rebuilds a list that stops at the first file, repairing an older scan', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + withStoredState( + db, + [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 240_000 }, + ], + [ + { absolutePath: partOne, durationSeconds: 360 }, + { absolutePath: partTwo, durationSeconds: 240 }, + ], + ); + probeReturns({ + [partOne]: { + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 240_000 }, + ], + durationMs: 360_000, + }, + [partTwo]: { chapters: [{ title: 'Chapter 3', startMs: 0 }], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false }); + + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + chapters: [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 2', startMs: 240_000 }, + { title: 'Chapter 3', startMs: 360_000 }, + ], + }), + ); + }); + + it('leaves an already merged list alone without probing the files again', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + withStoredState( + db, + [ + { title: 'Chapter 1', startMs: 0 }, + { title: 'Chapter 3', startMs: 360_000 }, + ], + [ + { absolutePath: partOne, durationSeconds: 360 }, + { absolutePath: partTwo, durationSeconds: 240 }, + ], + ); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false }); + + expect(mockProbeAudioChapters).not.toHaveBeenCalled(); + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('leaves a book with no stored chapters to the per-file fallback', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + withStoredState( + db, + [], + [ + { absolutePath: partOne, durationSeconds: 360 }, + { absolutePath: partTwo, durationSeconds: 240 }, + ], + ); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false }); + + expect(mockProbeAudioChapters).not.toHaveBeenCalled(); + expect(updateSet).not.toHaveBeenCalled(); + }); + + it('does not rebuild when a file length is unknown, since coverage cannot be judged', async () => { + const { db, updateSet } = makeDb(); + const service = makeService(db, undefined, { bookMetadataLockService: makeLockService() }); + withStoredState( + db, + [{ title: 'Chapter 1', startMs: 0 }], + [ + { absolutePath: partOne, durationSeconds: null }, + { absolutePath: partTwo, durationSeconds: 240 }, + ], + ); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: false }); + + expect(mockProbeAudioChapters).not.toHaveBeenCalled(); + expect(updateSet).not.toHaveBeenCalled(); + }); + }); + + it('writes nothing when the lock filter drops the chapters', async () => { + const { db, updateSet } = makeDb(); + const lockService = { + isFieldLocked: vi.fn().mockResolvedValue(false), + filterAutomatedBookUpdate: vi.fn().mockResolvedValue({ dto: {}, skippedFields: ['chapters'] }), + }; + const service = makeService(db, undefined, { bookMetadataLockService: lockService }); + + probeReturns({ + [partOne]: { chapters: [{ title: 'Chapter 1', startMs: 0 }], durationMs: 360_000 }, + [partTwo]: { chapters: [{ title: 'Chapter 2', startMs: 0 }], durationMs: 240_000 }, + }); + + await service.extractMergedAudioChapters(42, [partOne, partTwo], { filesChanged: true }); + + expect(updateSet).not.toHaveBeenCalled(); + }); + }); + describe('fixed-layout persistence', () => { function epubOpf(renditionLayout: string | null) { return { diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index 5365df8b5..801ee6b9f 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -33,8 +33,9 @@ import { ComicMetadataRepository } from './comic-metadata.repository'; import { MetadataScoreService } from '../metadata-score/metadata-score.service'; import { NarratorService } from '../narrator/narrator.service'; import { authors, bookAuthors, bookGenres, bookMetadata, books, bookTags, genres, tags } from '../../db/schema'; -import { type ComicMetadataFields, isAudioFormat } from '@bookorbit/types'; -import { parseAudioDuration } from './extractors/audio.extractor'; +import { type AudiobookChapter, type ComicMetadataFields, isAudioFormat } from '@bookorbit/types'; +import { chaptersReachLastFile, mergeAudioChapters, type AudioChapterSource } from './extractors/audio-chapter-merge'; +import { parseAudioDuration, probeAudioChapters } from './extractors/audio.extractor'; import type { ParsedBookData } from './extractors/format-extractor.interface'; import { generateThumbnail, imageExt } from './lib/cover'; import { METADATA_AUDIO_FORMATS, MetadataExtractionService } from './metadata-extraction.service'; @@ -282,6 +283,76 @@ export class MetadataService { ); } + /** + * Rebuilds the chapter list of an audiobook split across several files. + * + * Metadata extraction reads a single file, and each file of a multi-file audiobook embeds only its + * own chapters, numbered from zero. Probing every file in playback order is what turns those + * per-file lists into one book-length list. + * + * With no file changed this first checks the stored list against the files, so books scanned + * before chapters were merged are repaired without re-probing every audiobook on every scan. + */ + async extractMergedAudioChapters(bookId: number, absolutePaths: string[], options: { filesChanged: boolean }): Promise { + const event = 'metadata.merge_audio_chapters'; + const startedAt = Date.now(); + if (absolutePaths.length < 2) return; + if (!options.filesChanged && (await this.storedChaptersCoverBook(bookId, absolutePaths))) return; + + const sources: AudioChapterSource[] = []; + for (const absolutePath of absolutePaths) { + const { chapters, durationMs } = await probeAudioChapters(absolutePath); + sources.push({ absolutePath, chapters, durationMs }); + } + + const merged = mergeAudioChapters(sources); + if (merged === null) { + this.logger.warn( + `[${event}] [fail] bookId=${bookId} files=${absolutePaths.length} durationMs=${Date.now() - startedAt} errorClass=UnknownFileDuration error="a file length could not be read" - chapters left unchanged`, + ); + return; + } + if (merged.length === 0) { + this.logger.debug( + `[${event}] [end] bookId=${bookId} files=${absolutePaths.length} durationMs=${Date.now() - startedAt} chapters=0 - no embedded chapters to merge`, + ); + return; + } + + const { dto: filtered } = await this.bookMetadataLockService.filterAutomatedBookUpdate(bookId, { + audioMetadata: { chapters: merged }, + }); + if (filtered.audioMetadata?.chapters === undefined) return; + + await this.db + .update(bookMetadata) + .set({ chapters: filtered.audioMetadata.chapters, updatedAt: new Date() }) + .where(eq(bookMetadata.bookId, bookId)); + + this.logger.debug( + `[${event}] [end] bookId=${bookId} files=${absolutePaths.length} durationMs=${Date.now() - startedAt} chapters=${merged.length} - merged audio chapters saved`, + ); + } + + private async storedChaptersCoverBook(bookId: number, orderedAudioPaths: string[]): Promise { + const [meta] = await this.db.select({ chapters: bookMetadata.chapters }).from(bookMetadata).where(eq(bookMetadata.bookId, bookId)).limit(1); + const stored = (meta?.chapters ?? []) as AudiobookChapter[]; + if (stored.length === 0) return true; + + const fileRows = await this.db + .select({ absolutePath: schema.bookFiles.absolutePath, durationSeconds: schema.bookFiles.durationSeconds }) + .from(schema.bookFiles) + .where(eq(schema.bookFiles.bookId, bookId)); + + const durationByPath = new Map(fileRows.map((row) => [row.absolutePath, row.durationSeconds])); + const orderedDurationsMs = orderedAudioPaths.map((absolutePath) => { + const durationSeconds = durationByPath.get(absolutePath); + return durationSeconds === null || durationSeconds === undefined ? null : durationSeconds * 1000; + }); + + return chaptersReachLastFile(stored, orderedDurationsMs); + } + async extractAudioFileDuration(bookId: number, absolutePath: string): Promise { const durationSeconds = await parseAudioDuration(absolutePath); if (durationSeconds === null) return; diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index a9bd19edf..d2d0fecd4 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -147,6 +147,7 @@ const mockMetadata = { extractAudioFileDuration: vi.fn().mockResolvedValue(undefined), aggregateAudioDuration: vi.fn().mockResolvedValue(undefined), extractAudioChaptersAndNarrators: vi.fn().mockResolvedValue(undefined), + extractMergedAudioChapters: vi.fn().mockResolvedValue(undefined), }; function makeService( @@ -1562,6 +1563,171 @@ describe('audio multi-file audiobook', () => { expect(mockMetadata.aggregateAudioDuration).toHaveBeenCalledWith(expect.any(Number)); }); + it('merges chapters across every audio file of a multi-file audiobook, in playback order', async () => { + const file2 = makeFileStat({ absolutePath: '/library/Book/Book - 02.m4b', relPath: 'Book/Book - 02.m4b', format: 'm4b', role: 'content' }); + const file10 = makeFileStat({ + absolutePath: '/library/Book/Book - 10.m4b', + relPath: 'Book/Book - 10.m4b', + ino: 1010n, + format: 'm4b', + role: 'content', + }); + const file1 = makeFileStat({ + absolutePath: '/library/Book/Book - 01.m4b', + relPath: 'Book/Book - 01.m4b', + ino: 1001n, + format: 'm4b', + role: 'content', + }); + const candidate = makeCandidate('/library/Book', [file2, file10, file1]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + + const repo = makeRepo(); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + // Natural order, not lexicographic: file 10 plays after file 2, so its chapters offset last. + expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith( + expect.any(Number), + ['/library/Book/Book - 01.m4b', '/library/Book/Book - 02.m4b', '/library/Book/Book - 10.m4b'], + { filesChanged: true }, + ); + }); + + it('merges chapters for a multi-file audiobook whose files are the leading metadata source', async () => { + const file1 = makeFileStat({ absolutePath: '/library/Book/01.mp3', relPath: 'Book/01.mp3', format: 'mp3', role: 'content' }); + const file2 = makeFileStat({ absolutePath: '/library/Book/02.mp3', relPath: 'Book/02.mp3', ino: 1002n, format: 'mp3', role: 'content' }); + const candidate = makeCandidate('/library/Book', [file1, file2]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + + const repo = makeRepo({ + findLibrarySettings: vi.fn().mockResolvedValue({ + allowedFormats: [], + formatPriority: DEFAULT_FORMAT_PRIORITY, + metadataPrecedence: ['embedded', 'opfFile'], + excludePatterns: [], + organizationMode: 'book_per_folder', + }), + }); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + // Shared extraction reads only the winner, so the merge has to run for this path too. + expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(expect.any(Number), '/library/Book/01.mp3', 'mp3'); + expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(expect.any(Number), ['/library/Book/01.mp3', '/library/Book/02.mp3'], { + filesChanged: true, + }); + }); + + it('still checks chapters for an unchanged multi-file audiobook, so older scans get repaired', async () => { + const file1 = makeFileStat({ absolutePath: '/library/Book/01.m4b', relPath: 'Book/01.m4b', ino: 9001n, format: 'm4b', role: 'content' }); + const file2 = makeFileStat({ absolutePath: '/library/Book/02.m4b', relPath: 'Book/02.m4b', ino: 9002n, format: 'm4b', role: 'content' }); + + const repo = makeRepo({ + findBooksByLibraryFolder: vi + .fn() + .mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Book', status: 'present' }]), + findBookFilesByLibraryFolder: vi + .fn() + .mockResolvedValue([ + makeBookFile({ id: 10, bookId: 1, absolutePath: file1.absolutePath, ino: file1.ino }), + makeBookFile({ id: 11, bookId: 1, absolutePath: file2.absolutePath, ino: file2.ino }), + ]), + }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/library/Book', [file1, file2])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(mockMetadata.extractAndSave).not.toHaveBeenCalled(); + expect(mockMetadata.extractMergedAudioChapters).toHaveBeenCalledWith(expect.any(Number), ['/library/Book/01.m4b', '/library/Book/02.m4b'], { + filesChanged: false, + }); + }); + + it('leaves non-content audio files out of the merged chapter list', async () => { + const content = makeFileStat({ absolutePath: '/library/Book/01.m4b', relPath: 'Book/01.m4b', format: 'm4b', role: 'content' }); + const other = makeFileStat({ + absolutePath: '/library/Book/sample.mp3', + relPath: 'Book/sample.mp3', + ino: 1002n, + format: 'mp3', + role: 'metadata', + }); + const candidate = makeCandidate('/library/Book', [content, other]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + + const repo = makeRepo(); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(mockMetadata.extractMergedAudioChapters).not.toHaveBeenCalled(); + }); + + it('does not merge chapters for a single-file audiobook', async () => { + const candidate = makeCandidate('/library/Book', [makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b' })]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + + const repo = makeRepo(); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(mockMetadata.extractMergedAudioChapters).not.toHaveBeenCalled(); + }); + + it('does not merge chapters for books without audio files', async () => { + const epub1 = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', format: 'epub', role: 'content' }); + const epub2 = makeFileStat({ + absolutePath: '/library/Book/book.pdf', + relPath: 'Book/book.pdf', + ino: 1002n, + format: 'pdf', + role: 'content', + }); + const candidate = makeCandidate('/library/Book', [epub1, epub2]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + + const repo = makeRepo(); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(mockMetadata.extractMergedAudioChapters).not.toHaveBeenCalled(); + }); + + it('completes the scan when merging chapters fails', async () => { + const file1 = makeFileStat({ absolutePath: '/library/Book/01.m4b', relPath: 'Book/01.m4b', format: 'm4b', role: 'content' }); + const file2 = makeFileStat({ absolutePath: '/library/Book/02.m4b', relPath: 'Book/02.m4b', ino: 1002n, format: 'm4b', role: 'content' }); + const candidate = makeCandidate('/library/Book', [file1, file2]); + mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); + mockMetadata.extractMergedAudioChapters.mockRejectedValueOnce(new Error('ffprobe missing')); + + const repo = makeRepo(); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + + expect(repo.completeScanJob).toHaveBeenCalled(); + expect(repo.failScanJob).not.toHaveBeenCalled(); + }); + it('does not call aggregateAudioDuration for epub books', async () => { const candidate = makeCandidate('/library/Author/Book', [makeFileStat()]); mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() }); diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 879f013b9..cade891cd 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException, OnApplicationBootstrap, Optional } from '@nestjs/common'; import { createHash } from 'crypto'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { naturalCompare } from '../../common/utils/natural-sort.utils'; import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; import type { @@ -1639,6 +1640,25 @@ export class ScannerService implements OnApplicationBootstrap { } } + // 3e: Rebuild chapters across every audio file. Steps 3a and 3b read chapters from one file, and + // each file of a multi-file audiobook embeds only its own chapters starting at zero, so on + // its own that leaves the later files with no chapters at all and the last chapter of the + // first file stretched over the rest of the book. Runs for unchanged books too, which is + // what repairs the ones scanned before chapters were merged; it settles after one pass. + if (audioContentFiles.length > 1) { + const orderedAudioPaths = [...audioContentFiles] + .sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath))) + .map((file) => file.absolutePath); + const filesChanged = shouldExtractMetadata || changedAudioFiles.length > 0; + try { + await this.metadataService.extractMergedAudioChapters(book.id, orderedAudioPaths, { filesChanged }); + } catch (err) { + this.logger.warn( + `[scanner.merge_audio_chapters] [fail] bookId=${book.id} files=${orderedAudioPaths.length} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - audio chapter merge failed`, + ); + } + } + const becameVisible = await this.scannerRepo.promoteProcessingBookToPresent(book.id); return { bookId: book.id, ...counts, retainedFileIds, becameVisible, created: book.created }; }