From 0c02795a1cf0ae4cf107da94fe8a97ee61993e2b Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:45:16 +0000 Subject: [PATCH] fix(metadata): merge embedded chapters across every file of an audiobook Extraction reads one file, and each file of a multi-file audiobook embeds its own chapters starting at zero, so later files had no chapters and the first file's last chapter ran to the end of the book. Scanning now probes every audio file in playback order and offsets each file's chapters by the files before it, rebuilding books whose stored list stops short. Closes #1146 --- .../extractors/audio-chapter-merge.test.ts | 254 +++++++++++++++++ .../extractors/audio-chapter-merge.ts | 79 ++++++ .../extractors/audio.extractor.test.ts | 99 ++++++- .../metadata/extractors/audio.extractor.ts | 39 ++- .../modules/metadata/metadata.service.test.ts | 266 +++++++++++++++++- .../src/modules/metadata/metadata.service.ts | 75 ++++- .../modules/scanner/scanner.service.test.ts | 166 +++++++++++ server/src/modules/scanner/scanner.service.ts | 20 ++ 8 files changed, 989 insertions(+), 9 deletions(-) create mode 100644 server/src/modules/metadata/extractors/audio-chapter-merge.test.ts create mode 100644 server/src/modules/metadata/extractors/audio-chapter-merge.ts 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 }; }