fix(metadata): extract and save audiobook chapters and embedded metadata

The book dock metadata service now routes audio formats through the audio extractor to extract chapters, duration, narrators, and other embedded tags. This ensures that audiobook metadata is correctly mapped and persisted during the import process.

Closes #322
This commit is contained in:
neon
2026-06-13 17:18:40 +00:00
parent 702cb267ba
commit d42abb3647
5 changed files with 392 additions and 2 deletions
+5
View File
@@ -1,3 +1,5 @@
import type { AudiobookChapter } from "./audiobook";
export type BookDockFileStatus = "pending" | "extracting" | "fetching" | "ready" | "error";
export type BookDockAutoFinalizeMetadataMode = "safe_merge" | "fetched_only" | "embedded_only";
@@ -5,6 +7,7 @@ export interface BookDockMetadata {
title?: string;
subtitle?: string;
authors?: string[];
narrators?: string[];
description?: string;
publisher?: string;
publishedYear?: number;
@@ -16,6 +19,8 @@ export interface BookDockMetadata {
seriesIndex?: number;
genres?: string[];
coverUrl?: string;
durationSeconds?: number;
chapters?: AudiobookChapter[];
}
export interface BookDockFile {
@@ -43,6 +43,7 @@ function makeService() {
saveExtractedCoverBytes: vi.fn().mockResolvedValue(undefined),
replaceAuthors: vi.fn().mockResolvedValue(undefined),
replaceGenres: vi.fn().mockResolvedValue(undefined),
replaceNarrators: vi.fn().mockResolvedValue(undefined),
};
const validator = {
validateFormat: vi.fn(),
@@ -681,6 +682,165 @@ describe('BookDockFinalizeService', () => {
expect(mockReadFile).not.toHaveBeenCalled();
});
it('applyMetadata persists duration, chapters and narrators extracted from the audiobook', async () => {
const { service, db, metadataService } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
await (service as any).applyMetadata(
30,
makeRow({
format: 'm4b',
fileName: 'book.m4b',
selectedMetadata: null,
coverPath: null,
embeddedMetadata: {
title: 'Artificial Condition',
authors: ['Martha Wells'],
narrators: ['Kevin R. Free'],
durationSeconds: 12218,
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 804850 },
],
} as BookDockMetadata,
}),
);
expect(updateChain.set).toHaveBeenCalledWith(
expect.objectContaining({
durationSeconds: 12218,
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 804850 },
],
}),
);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(30, [{ name: 'Martha Wells', sortName: null }]);
expect(metadataService.replaceNarrators).toHaveBeenCalledWith(30, [{ name: 'Kevin R. Free', sortName: null }]);
});
it('applyMetadata keeps audio facts from embeddedMetadata even when scalar fields were edited', async () => {
const { service, db, metadataService } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
await (service as any).applyMetadata(
31,
makeRow({
format: 'm4b',
selectedMetadata: { title: 'Edited Title' } as BookDockMetadata,
coverPath: null,
embeddedMetadata: {
title: 'Original Title',
durationSeconds: 555,
narrators: ['Reader One'],
chapters: [{ title: 'Intro', startMs: 10 }],
} as BookDockMetadata,
}),
);
expect(updateChain.set).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Edited Title',
durationSeconds: 555,
chapters: [{ title: 'Intro', startMs: 10 }],
}),
);
expect(metadataService.replaceNarrators).toHaveBeenCalledWith(31, [{ name: 'Reader One', sortName: null }]);
});
it('applyMetadata omits audio fields and skips narrators when none were extracted', async () => {
const { service, db, metadataService } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
await (service as any).applyMetadata(
32,
makeRow({
selectedMetadata: null,
coverPath: null,
embeddedMetadata: { title: 'Just a Book' } as BookDockMetadata,
}),
);
const patch = updateChain.set.mock.calls[0]?.[0] as Record<string, unknown>;
expect(patch).not.toHaveProperty('durationSeconds');
expect(patch).not.toHaveProperty('chapters');
expect(metadataService.replaceNarrators).not.toHaveBeenCalled();
});
it('applyMetadata sanitizes malformed chapters and drops non-positive duration before persisting', async () => {
const { service, db, metadataService } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
await (service as any).applyMetadata(
33,
makeRow({
selectedMetadata: null,
coverPath: null,
embeddedMetadata: {
durationSeconds: 0,
chapters: [
{ title: 'Good', startMs: 1000 },
{ title: 'NoStart' },
{ title: 'Negative', startMs: -5 },
{ startMs: 2000 },
'garbage',
{ title: 'Stringy', startMs: '3000.7' },
],
} as unknown as BookDockMetadata,
}),
);
const patch = updateChain.set.mock.calls[0]?.[0] as Record<string, unknown>;
expect(patch).not.toHaveProperty('durationSeconds');
expect(patch.chapters).toEqual([
{ title: 'Good', startMs: 1000 },
{ title: '', startMs: 2000 },
{ title: 'Stringy', startMs: 3001 },
]);
expect(metadataService.replaceNarrators).not.toHaveBeenCalled();
});
it('applyMetadata coerces a string-typed duration into a rounded integer', async () => {
const { service, db } = makeService();
const updateChain = {
set: vi.fn(),
where: vi.fn().mockResolvedValue(undefined),
};
updateChain.set.mockReturnValue(updateChain);
db.update.mockReturnValue(updateChain);
await (service as any).applyMetadata(
34,
makeRow({
selectedMetadata: null,
coverPath: null,
embeddedMetadata: { durationSeconds: '999.6' } as unknown as BookDockMetadata,
}),
);
expect(updateChain.set).toHaveBeenCalledWith(expect.objectContaining({ durationSeconds: 1000 }));
});
it('applyMetadata falls back to extracted cover bytes when cover download is unavailable', async () => {
const { service, db, metadataService } = makeService();
metadataService.downloadAndSaveCover.mockResolvedValueOnce(false);
@@ -4,7 +4,13 @@ import { access as fsAccess, readFile, stat, unlink } from 'fs/promises';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { BookDockAutoFinalizeMetadataMode, BookDockFinalizeFileResult, BookDockFinalizeResult, BookDockMetadata } from '@bookorbit/types';
import type {
AudiobookChapter,
BookDockAutoFinalizeMetadataMode,
BookDockFinalizeFileResult,
BookDockFinalizeResult,
BookDockMetadata,
} from '@bookorbit/types';
import { NotificationType, resolveUploadPath } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
@@ -551,6 +557,7 @@ export class BookDockFinalizeService implements OnModuleInit {
private async applyMetadata(bookId: number, row: BookDockFileRow): Promise<void> {
const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata);
const audio = resolveAudioFinalizeFields(row.embeddedMetadata ?? row.selectedMetadata);
let selectedCoverApplied = false;
const selectedCoverUrl = meta.coverUrl;
@@ -583,7 +590,10 @@ export class BookDockFinalizeService implements OnModuleInit {
};
const patch = (await this.seriesIdentity?.resolveMetadataPatch(scalarFields)) ?? scalarFields;
await this.db.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
await this.db
.update(bookMetadata)
.set({ ...patch, ...buildAudioMetadataPatch(audio) })
.where(eq(bookMetadata.bookId, bookId));
if (meta.authors.length > 0) {
await this.metadataService.replaceAuthors(
@@ -595,6 +605,13 @@ export class BookDockFinalizeService implements OnModuleInit {
if (meta.genres.length > 0) {
await this.metadataService.replaceGenres(bookId, meta.genres);
}
if (audio.narrators.length > 0) {
await this.metadataService.replaceNarrators(
bookId,
audio.narrators.map((name) => ({ name, sortName: null })),
);
}
}
private async cleanupBookDockRecord(row: BookDockFileRow): Promise<void> {
@@ -722,6 +739,52 @@ function normalizeFinalizeMetadata(meta: BookDockMetadata | null | undefined): N
};
}
type AudioFinalizeFields = {
durationSeconds: number | null;
chapters: AudiobookChapter[] | null;
narrators: string[];
};
function resolveAudioFinalizeFields(meta: BookDockMetadata | null | undefined): AudioFinalizeFields {
return {
durationSeconds: normalizeDurationSeconds(meta?.durationSeconds),
chapters: normalizeChapters(meta?.chapters),
narrators: normalizeStringArray(meta?.narrators, 500),
};
}
function buildAudioMetadataPatch(audio: AudioFinalizeFields): { durationSeconds?: number; chapters?: AudiobookChapter[] } {
const patch: { durationSeconds?: number; chapters?: AudiobookChapter[] } = {};
if (audio.durationSeconds !== null) patch.durationSeconds = audio.durationSeconds;
if (audio.chapters !== null) patch.chapters = audio.chapters;
return patch;
}
function normalizeDurationSeconds(value: unknown): number | null {
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value.trim()) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.round(parsed);
}
function normalizeChapters(value: unknown): AudiobookChapter[] | null {
if (!Array.isArray(value)) return null;
const chapters: AudiobookChapter[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const candidate = item as { title?: unknown; startMs?: unknown };
const startMs =
typeof candidate.startMs === 'number'
? candidate.startMs
: typeof candidate.startMs === 'string'
? Number.parseFloat(candidate.startMs.trim())
: NaN;
if (!Number.isFinite(startMs) || startMs < 0) continue;
const title = typeof candidate.title === 'string' ? candidate.title : '';
chapters.push({ title, startMs: Math.round(startMs) });
}
return chapters.length > 0 ? chapters : null;
}
function resolveFinalizeErrorMessage(error: unknown): string {
if (isPublishedYearConstraintViolation(error)) {
return INVALID_PUBLISHED_YEAR_MESSAGE;
@@ -31,6 +31,10 @@ vi.mock('../metadata/lib/cover', () => ({
imageExt: vi.fn(),
}));
vi.mock('../metadata/extractors/audio.extractor', () => ({
extractAudioMetadata: vi.fn(),
}));
import { Logger } from '@nestjs/common';
import { mkdir, writeFile } from 'fs/promises';
import type { MockedFunction } from 'vitest';
@@ -40,9 +44,31 @@ import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover';
import { extractAudioMetadata, type AudioExtractResult } from '../metadata/extractors/audio.extractor';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { BookDockMetadataService } from './book-dock-metadata.service';
function makeAudioResult(overrides?: Partial<AudioExtractResult>): AudioExtractResult {
return {
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
durationSeconds: null,
chapters: [],
coverBytes: null,
...overrides,
};
}
const mockMkdir = mkdir as MockedFunction<typeof mkdir>;
const mockWriteFile = writeFile as MockedFunction<typeof writeFile>;
const mockExtractCover = extractCover as MockedFunction<typeof extractCover>;
@@ -55,6 +81,7 @@ const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extra
const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction<typeof extractCbrMetadata>;
const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction<typeof extractCb7Metadata>;
const mockParseFb2File = parseFb2File as MockedFunction<typeof parseFb2File>;
const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction<typeof extractAudioMetadata>;
describe('BookDockMetadataService', () => {
const repo = {
@@ -76,6 +103,7 @@ describe('BookDockMetadataService', () => {
mockExtractCbrMetadata.mockResolvedValue(null);
mockExtractCb7Metadata.mockResolvedValue(null);
mockParseFb2File.mockResolvedValue(null);
mockExtractAudioMetadata.mockResolvedValue(makeAudioResult());
});
it('extractAndSave(pdf) reuses a single parse result for metadata and cover persistence', async () => {
@@ -366,4 +394,114 @@ describe('BookDockMetadataService', () => {
}),
);
});
it('extractAndSave(m4b) extracts chapters, duration, narrators and embedded cover in a single parse', async () => {
mockExtractAudioMetadata.mockResolvedValue(
makeAudioResult({
title: 'Artificial Condition',
subtitle: 'The Murderbot Diaries',
authors: [{ name: 'Martha Wells', sortName: null }],
narrators: ['Kevin R. Free'],
publisher: 'Recorded Books',
publishedYear: 2018,
description: 'Murderbot returns.',
language: 'en',
seriesName: 'The Murderbot Diaries',
seriesIndex: 2,
genres: ['Science Fiction'],
durationSeconds: 12218,
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 804850 },
],
coverBytes: Buffer.from('cover'),
}),
);
mockImageExt.mockReturnValue('jpg');
const service = new BookDockMetadataService(repo as never);
await service.extractAndSave(10, '/tmp/book.m4b', 'm4b', '/tmp/covers');
expect(mockExtractAudioMetadata).toHaveBeenCalledTimes(1);
expect(mockExtractAudioMetadata).toHaveBeenCalledWith('/tmp/book.m4b');
expect(mockExtractCover).not.toHaveBeenCalled();
expect(repo.update).toHaveBeenNthCalledWith(2, 10, {
embeddedMetadata: {
title: 'Artificial Condition',
subtitle: 'The Murderbot Diaries',
description: 'Murderbot returns.',
publisher: 'Recorded Books',
publishedYear: 2018,
language: 'en',
seriesName: 'The Murderbot Diaries',
seriesIndex: 2,
authors: ['Martha Wells'],
narrators: ['Kevin R. Free'],
genres: ['Science Fiction'],
durationSeconds: 12218,
chapters: [
{ title: 'Chapter 1', startMs: 0 },
{ title: 'Chapter 2', startMs: 804850 },
],
},
coverPath: '/tmp/covers/10.jpg',
status: 'ready',
});
expect(mockWriteFile).toHaveBeenCalledWith('/tmp/covers/10.jpg', Buffer.from('cover'));
});
it('extractAndSave(mp3) omits absent fields and leaves cover null when there is no embedded art', async () => {
mockExtractAudioMetadata.mockResolvedValue(
makeAudioResult({
title: 'Solo Track',
durationSeconds: 3600,
chapters: [],
coverBytes: null,
}),
);
const service = new BookDockMetadataService(repo as never);
await service.extractAndSave(11, '/tmp/book.mp3', 'mp3', '/tmp/covers');
const patch = repo.update.mock.calls[1]?.[1] as {
embeddedMetadata: Record<string, unknown>;
coverPath: string | null;
status: string;
};
expect(patch.status).toBe('ready');
expect(patch.coverPath).toBeNull();
expect(patch.embeddedMetadata).toEqual(expect.objectContaining({ title: 'Solo Track', durationSeconds: 3600 }));
expect(patch.embeddedMetadata.chapters).toBeUndefined();
expect(patch.embeddedMetadata.narrators).toBeUndefined();
expect(mockWriteFile).not.toHaveBeenCalled();
});
it('extractAndSave(audio) stays ready with empty metadata when the file has no readable tags', async () => {
mockExtractAudioMetadata.mockResolvedValue(makeAudioResult());
const service = new BookDockMetadataService(repo as never);
await service.extractAndSave(12, '/tmp/empty.flac', 'flac', '/tmp/covers');
const patch = repo.update.mock.calls[1]?.[1] as { embeddedMetadata: Record<string, unknown>; coverPath: string | null; status: string };
expect(patch.status).toBe('ready');
expect(patch.coverPath).toBeNull();
expect(Object.values(patch.embeddedMetadata).every((value) => value === undefined)).toBe(true);
});
it.each(['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'])(
'routes %s uploads through the audio extractor, not the generic cover path',
async (format) => {
mockExtractAudioMetadata.mockResolvedValue(makeAudioResult({ durationSeconds: 100 }));
const service = new BookDockMetadataService(repo as never);
await service.extractAndSave(1, `/tmp/audio.${format}`, format, '/tmp/covers');
expect(mockExtractAudioMetadata).toHaveBeenCalledWith(`/tmp/audio.${format}`);
expect(mockExtractCover).not.toHaveBeenCalled();
},
);
});
@@ -3,11 +3,13 @@ import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import type { BookDockMetadata } from '@bookorbit/types';
import { isAudioFormat } from '@bookorbit/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParsed, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { extractAudioMetadata, type AudioExtractResult } from '../metadata/extractors/audio.extractor';
import { extractCover, generateThumbnail, imageExt } from '../metadata/lib/cover';
import { detectComicContainerFormat } from '../../common/comic-format-detect';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
@@ -30,6 +32,10 @@ export class BookDockMetadataService {
const parsedPdf = await this.extractPdfData(absolutePath, true);
metadata = this.toPdfMetadata(parsedPdf);
coverBytes = parsedPdf?.coverBuffer ?? null;
} else if (isAudioFormat(format)) {
const audio = await extractAudioMetadata(absolutePath);
metadata = this.toAudioMetadata(audio);
coverBytes = audio.coverBytes;
} else {
[metadata, coverBytes] = await Promise.all([this.extractMetadata(absolutePath, format), extractCover(absolutePath, format)]);
}
@@ -172,6 +178,24 @@ export class BookDockMetadataService {
});
}
private toAudioMetadata(audio: AudioExtractResult): BookDockMetadata {
return {
title: audio.title ?? undefined,
subtitle: audio.subtitle ?? undefined,
description: audio.description ?? undefined,
publisher: audio.publisher ?? undefined,
publishedYear: audio.publishedYear ?? undefined,
language: audio.language ?? undefined,
seriesName: audio.seriesName ?? undefined,
seriesIndex: audio.seriesIndex ?? undefined,
authors: audio.authors.length > 0 ? audio.authors.map((a) => a.name) : undefined,
narrators: audio.narrators.length > 0 ? audio.narrators : undefined,
genres: audio.genres.length > 0 ? audio.genres : undefined,
durationSeconds: audio.durationSeconds ?? undefined,
chapters: audio.chapters.length > 0 ? audio.chapters : undefined,
};
}
private toPdfMetadata(parsed: PdfParsed | null): BookDockMetadata {
if (!parsed) return {};
return {