mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user