fix(books): extract chapters from attached audiobooks

Preserve embedded chapters and total duration when audio joins an EPUB-primary book.

Fixes #1532
This commit is contained in:
neon
2026-09-26 05:48:47 +00:00
parent a52d005ff5
commit 995cc24cc5
6 changed files with 135 additions and 6 deletions
@@ -76,6 +76,7 @@ vi.mock('./extractors/audio.extractor', () => ({
}));
import sharp from 'sharp';
import { PgDialect } from 'drizzle-orm/pg-core';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
@@ -1999,23 +2000,43 @@ describe('MetadataService', () => {
expect(primaryWhere).toHaveBeenCalledTimes(1);
expect(aggregateWhere).toHaveBeenCalledTimes(1);
expect(new PgDialect().sqlToQuery(aggregateWhere.mock.calls[0]![0]).params).toEqual([42, 'content', 'm4b']);
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 3600 });
});
it('aggregateAudioDuration no-ops when selected primary file is not an audio format', async () => {
const primaryWhere = vi.fn().mockResolvedValue([]);
it('aggregateAudioDuration sums attached audio when an EPUB is primary', async () => {
const primaryWhere = vi.fn().mockResolvedValue([{ format: 'epub' }]);
const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere });
const primaryFrom = vi.fn().mockReturnValue({ innerJoin: primaryInnerJoin });
const aggregateWhere = vi.fn().mockResolvedValue([{ total: 3600 }]);
const aggregateFrom = vi.fn().mockReturnValue({ where: aggregateWhere });
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
const db = {
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }),
update: vi.fn(),
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }).mockReturnValueOnce({ from: aggregateFrom }),
update: vi.fn().mockReturnValue({ set: updateSet }),
};
const service = makeService(db);
await service.aggregateAudioDuration(99);
expect(new PgDialect().sqlToQuery(aggregateWhere.mock.calls[0]![0]).params).toEqual([99, 'content', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac']);
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 3600 });
});
it('aggregateAudioDuration no-ops without a selected primary file', async () => {
const primaryWhere = vi.fn().mockResolvedValue([]);
const primaryInnerJoin = vi.fn().mockReturnValue({ where: primaryWhere });
const primaryFrom = vi.fn().mockReturnValue({ innerJoin: primaryInnerJoin });
const db = {
select: vi.fn().mockReturnValueOnce({ from: primaryFrom }),
update: vi.fn(),
};
const service = makeService(db);
await service.aggregateAudioDuration(99);
expect(db.update).not.toHaveBeenCalled();
});
});
@@ -451,13 +451,14 @@ export class MetadataService {
.select({ format: schema.bookFiles.format })
.from(schema.books)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
.where(and(eq(schema.books.id, bookId), inArray(schema.bookFiles.format, [...METADATA_AUDIO_FORMATS])));
.where(eq(schema.books.id, bookId));
if (!primary?.format) return;
const audioFormats = isAudioFormat(primary.format) ? [primary.format] : [...METADATA_AUDIO_FORMATS];
const rows = await this.db
.select({ total: sql<number>`COALESCE(SUM(${schema.bookFiles.durationSeconds}), 0)` })
.from(schema.bookFiles)
.where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.role, 'content'), eq(schema.bookFiles.format, primary.format)));
.where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.role, 'content'), inArray(schema.bookFiles.format, audioFormats)));
const total = Number(rows[0]?.total ?? 0);
if (total > 0) {
@@ -16,6 +16,8 @@ describe('UploadProcessorService', () => {
const metadataService = {
extractAndSave: vi.fn(),
extractAndAggregateAudioDuration: vi.fn(),
extractAudioChaptersAndNarrators: vi.fn(),
extractMergedAudioChapters: vi.fn(),
};
const orchestrator = {
scheduleImportedBooksIfEligible: vi.fn(),
@@ -69,13 +71,17 @@ describe('UploadProcessorService', () => {
};
const db = {
select: vi.fn(),
transaction: vi.fn(async (callback: (innerTx: typeof tx) => Promise<unknown>) => callback(tx)),
};
const audioFilesWhere = vi.fn();
let service: UploadProcessorService;
beforeEach(() => {
vi.resetAllMocks();
db.select.mockReturnValue({ from: () => ({ where: audioFilesWhere }) });
audioFilesWhere.mockResolvedValue([{ absolutePath: '/path/to/library/book.m4b', format: 'm4b' }]);
selectFrom.mockReturnValue({ where: selectWhere, innerJoin: selectInnerJoin });
selectInnerJoin.mockReturnValue({ where: selectWhere });
@@ -336,6 +342,48 @@ describe('UploadProcessorService', () => {
expect(warn).toHaveBeenCalledWith(expect.stringContaining('aggregate failed'));
});
it('extractAddedAudioChaptersAsync ignores non-audio formats', () => {
service.extractAddedAudioChaptersAsync(7, 'epub');
expect(db.select).not.toHaveBeenCalled();
});
it('extractAddedAudioChaptersAsync extracts chapters for a single audio file', async () => {
metadataService.extractAudioChaptersAndNarrators.mockResolvedValue(undefined);
service.extractAddedAudioChaptersAsync(7, 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAudioChaptersAndNarrators).toHaveBeenCalledWith(7, '/path/to/library/book.m4b', 'm4b');
expect(metadataService.extractMergedAudioChapters).not.toHaveBeenCalled();
});
it('extractAddedAudioChaptersAsync merges chapters across files in playback order', async () => {
audioFilesWhere.mockResolvedValue([
{ absolutePath: '/path/to/library/10.mp3', format: 'mp3' },
{ absolutePath: '/path/to/library/2.mp3', format: 'mp3' },
]);
metadataService.extractAudioChaptersAndNarrators.mockResolvedValue(undefined);
metadataService.extractMergedAudioChapters.mockResolvedValue(undefined);
service.extractAddedAudioChaptersAsync(7, 'mp3');
await new Promise((resolve) => setImmediate(resolve));
expect(metadataService.extractAudioChaptersAndNarrators).toHaveBeenCalledWith(7, '/path/to/library/2.mp3', 'mp3');
expect(metadataService.extractMergedAudioChapters).toHaveBeenCalledWith(7, ['/path/to/library/2.mp3', '/path/to/library/10.mp3'], {
filesChanged: true,
});
});
it('extractAddedAudioChaptersAsync logs and suppresses extraction errors', async () => {
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation();
metadataService.extractAudioChaptersAndNarrators.mockRejectedValue(new Error('chapter probe failed'));
service.extractAddedAudioChaptersAsync(7, 'm4b');
await new Promise((resolve) => setImmediate(resolve));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('chapter probe failed'));
});
describe('with undefined orchestrator', () => {
let serviceNoOrch: UploadProcessorService;
@@ -1,11 +1,13 @@
import { Inject, Injectable, InternalServerErrorException, Logger, Optional } from '@nestjs/common';
import { stat } from 'fs/promises';
import { basename } from 'path';
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { isAudioFormat } from '@bookorbit/types';
import type { FileRole as BookFileRole } from '../scanner/lib/classify';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { selectPrimaryFileKeepingCurrent } from '../../common/utils/primary-file-selection.utils';
import { naturalCompare } from '../../common/utils/natural-sort.utils';
import { FORMATS_WITH_UNBOUNDED_METADATA_READS, MAX_BUFFERED_METADATA_BYTES } from '../../common/constants/upload.constants';
import { DB } from '../../db';
@@ -15,6 +17,7 @@ import { BookMetadataFetchOrchestratorService } from '../book-metadata-fetch/boo
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
import { CoverSlotReconciler } from '../metadata/cover-slot-reconciler.service';
import { MetadataService } from '../metadata/metadata.service';
import { METADATA_AUDIO_FORMATS } from '../metadata/metadata-extraction.service';
import { computeFileHash } from '../scanner/lib/hash';
import { inspectEpubMediaOverlayFields } from '../reader/epub/epub-media-overlay-capability';
@@ -464,4 +467,43 @@ export class UploadProcessorService {
);
});
}
extractAddedAudioChaptersAsync(bookId: number, format: string): void {
if (!isAudioFormat(format)) return;
const event = 'book.extract_added_audio_chapters';
const startedAt = Date.now();
this.logger.debug(`[${event}] [start] bookId=${bookId} format=${format} - added audio chapter extraction started`);
this.extractAddedAudioChapters(bookId)
.then(() => {
this.logger.debug(
`[${event}] [end] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} - added audio chapter extraction completed`,
);
})
.catch((err: Error) => {
const errorClass = err.name ?? 'Error';
const errorMessage = sanitizeLogValue(err.message);
this.logger.warn(
`[${event}] [fail] bookId=${bookId} format=${format} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - added audio chapter extraction failed`,
);
});
}
private async extractAddedAudioChapters(bookId: number): Promise<void> {
const files = await this.db
.select({ absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'content'), inArray(bookFiles.format, [...METADATA_AUDIO_FORMATS])));
const orderedFiles = files.sort((left, right) => naturalCompare(basename(left.absolutePath), basename(right.absolutePath)));
const firstAudio = orderedFiles[0];
if (!firstAudio?.format) return;
await this.metadataService.extractAudioChaptersAndNarrators(bookId, firstAudio.absolutePath, firstAudio.format);
if (orderedFiles.length > 1) {
await this.metadataService.extractMergedAudioChapters(
bookId,
orderedFiles.map((file) => file.absolutePath),
{ filesChanged: true },
);
}
}
}
@@ -102,6 +102,7 @@ describe('UploadService', () => {
processNewBookImportAsync: vi.fn(),
extractMetadataAsync: vi.fn(),
extractAudioDurationAsync: vi.fn(),
extractAddedAudioChaptersAsync: vi.fn(),
reconcileCoversAsync: vi.fn(),
};
@@ -1093,6 +1094,20 @@ describe('UploadService', () => {
expect(processor.reconcileCoversAsync).toHaveBeenCalledWith([10]);
});
it('schedules audio chapter extraction when an M4B is added to an EPUB-primary book', async () => {
mockUploadedFormat('m4b');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub', 'm4b'] }, [
{ id: 99, format: 'epub', sizeBytes: 1000 },
{ id: 55, format: 'm4b', sizeBytes: 456 },
]);
await service.addFileToBook(10, 'book.m4b', {} as any, user);
expect(processor.extractAudioDurationAsync).toHaveBeenCalledWith(10, expect.stringMatching(/book\.m4b$/), 'm4b');
expect(processor.extractAddedAudioChaptersAsync).toHaveBeenCalledWith(10, 'm4b');
});
it('delegates duration extraction to the processor regardless of format (processor gates on audio)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
@@ -1238,6 +1253,7 @@ describe('UploadService', () => {
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('primary update failed');
expect(processor.extractAudioDurationAsync).not.toHaveBeenCalled();
expect(processor.extractAddedAudioChaptersAsync).not.toHaveBeenCalled();
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
});
@@ -337,6 +337,7 @@ export class UploadService {
});
this.processor.extractAudioDurationAsync(bookId, destination, format);
this.processor.extractAddedAudioChaptersAsync(bookId, format);
this.processor.reconcileCoversAsync([bookId]);
this.logger.log(