mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(books): measure audiobook durations when Book Dock files them
Book Dock finalize, which also files book requests, left each audio file's duration empty until the next library scan. The player derives progress only from those, so a filed audiobook stayed at 0% and never reached Continue Listening. Finalize now measures them once metadata is applied. Refs #1757
This commit is contained in:
@@ -62,6 +62,7 @@ function makeService() {
|
|||||||
replaceGenres: vi.fn().mockResolvedValue(undefined),
|
replaceGenres: vi.fn().mockResolvedValue(undefined),
|
||||||
replaceNarrators: vi.fn().mockResolvedValue(undefined),
|
replaceNarrators: vi.fn().mockResolvedValue(undefined),
|
||||||
upsertComicMetadata: vi.fn().mockResolvedValue(undefined),
|
upsertComicMetadata: vi.fn().mockResolvedValue(undefined),
|
||||||
|
measureMissingAudioDurations: vi.fn().mockResolvedValue({ measured: 0, failed: 0 }),
|
||||||
};
|
};
|
||||||
const metadataScoreService = {
|
const metadataScoreService = {
|
||||||
calculateAndSave: vi.fn().mockResolvedValue(undefined),
|
calculateAndSave: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -735,6 +736,20 @@ describe('BookDockFinalizeService', () => {
|
|||||||
expect(applyMetadata).not.toHaveBeenCalled();
|
expect(applyMetadata).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Issue 1757: an audiobook joining an ebook was filed with no duration, so listening stayed at 0%. */
|
||||||
|
it('measures the audio durations of the book it joined', async () => {
|
||||||
|
const { service, processor, metadataService } = makeService();
|
||||||
|
stubFolderLibrary(service);
|
||||||
|
vi.spyOn(service as never, 'applyJoinedMetadata').mockResolvedValue(undefined as never);
|
||||||
|
mockAccess.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||||
|
mockStat.mockResolvedValueOnce({ size: 100 } as never);
|
||||||
|
processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [42], createdBookIds: [], attachedFileIds: [900] });
|
||||||
|
|
||||||
|
await (service as any).finalizeFile(audioRow(), undefined, undefined, new Map([[1, { joinBook }]]), 1, true);
|
||||||
|
|
||||||
|
expect(metadataService.measureMissingAudioDurations.mock.calls).toEqual([[42]]);
|
||||||
|
});
|
||||||
|
|
||||||
it('files the ordinary way in a library where a book is a single file', async () => {
|
it('files the ordinary way in a library where a book is a single file', async () => {
|
||||||
const { service, storage } = makeService();
|
const { service, storage } = makeService();
|
||||||
stubFolderLibrary(service, 'book_per_file');
|
stubFolderLibrary(service, 'book_per_file');
|
||||||
@@ -2387,6 +2402,113 @@ describe('BookDockFinalizeService', () => {
|
|||||||
expect(harness.processor.reconcileCoversAsync).toHaveBeenCalledWith([81, 82]);
|
expect(harness.processor.reconcileCoversAsync).toHaveBeenCalledWith([81, 82]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue 1757: the player derives progress only from per-file durations, and finalize used to
|
||||||
|
* leave them empty until the next scan, so a filed audiobook stayed at 0% and never reached
|
||||||
|
* Continue Listening.
|
||||||
|
*/
|
||||||
|
describe('audio durations', () => {
|
||||||
|
it('measures every book the unit produced, after its metadata and before any write-back', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES);
|
||||||
|
arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true });
|
||||||
|
harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [101, 102], attachedFileIds: [] });
|
||||||
|
const order: string[] = [];
|
||||||
|
vi.spyOn(harness.service as never, 'applyMetadata').mockImplementation(((bookId: number) => {
|
||||||
|
order.push(`metadata:${bookId}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
}) as never);
|
||||||
|
harness.metadataService.measureMissingAudioDurations.mockImplementation((bookId: number) => {
|
||||||
|
order.push(`measure:${bookId}`);
|
||||||
|
return Promise.resolve({ measured: 1, failed: 0 });
|
||||||
|
});
|
||||||
|
harness.fileWriteService.scheduleWrite.mockImplementation((bookId: number) => {
|
||||||
|
order.push(`write:${bookId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await finalize(harness, multiFormatRow());
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(order).toEqual(['metadata:101', 'metadata:102', 'measure:101', 'measure:102', 'write:101', 'write:102']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures the one book of a multi-track audiobook', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES);
|
||||||
|
arrange(harness);
|
||||||
|
harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] });
|
||||||
|
|
||||||
|
const result = await finalize(harness, unitRow());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, bookId: 77 });
|
||||||
|
expect(harness.metadataService.measureMissingAudioDurations.mock.calls).toEqual([[77]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the book filed when measuring fails, and still measures the next book', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(MULTI_FORMAT_FILES);
|
||||||
|
arrange(harness, { organizationMode: 'book_per_file', destPath: '/library/Dune.epub', fileWriteEnabled: true });
|
||||||
|
harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [101, 102], createdBookIds: [101, 102], attachedFileIds: [] });
|
||||||
|
harness.metadataService.measureMissingAudioDurations
|
||||||
|
.mockRejectedValueOnce(new Error('connection reset'))
|
||||||
|
.mockResolvedValueOnce({ measured: 1, failed: 0 });
|
||||||
|
const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = await finalize(harness, multiFormatRow());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, bookId: 101 });
|
||||||
|
expect(harness.metadataService.measureMissingAudioDurations.mock.calls).toEqual([[101], [102]]);
|
||||||
|
expect(harness.processor.deleteUnitBookRecords).not.toHaveBeenCalled();
|
||||||
|
// Only the forward moves: nothing went back to the dock.
|
||||||
|
expect(harness.storage.moveToPath).toHaveBeenCalledTimes(2);
|
||||||
|
expect(harness.fileWriteService.scheduleWrite).toHaveBeenCalledTimes(2);
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\[book_dock\.measure_audio_durations\] \[fail\] fileId=42 bookId=101 .*errorClass=Error error="connection reset"/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs files it could not measure without failing the import', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES);
|
||||||
|
arrange(harness);
|
||||||
|
harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] });
|
||||||
|
harness.metadataService.measureMissingAudioDurations.mockResolvedValueOnce({ measured: 1, failed: 1 });
|
||||||
|
const warn = vi.spyOn((harness.service as any).logger, 'warn').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = await finalize(harness, unitRow());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ success: true, bookId: 77 });
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\[book_dock\.measure_audio_durations\] \[end\] fileId=42 bookId=77 .*measured=1 failed=1/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures nothing when the metadata pass fails and the unit is taken back', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES);
|
||||||
|
arrange(harness);
|
||||||
|
harness.processor.createUnitBookRecords.mockResolvedValue({ bookIds: [77], createdBookIds: [77], attachedFileIds: [] });
|
||||||
|
vi.spyOn(harness.service as never, 'applyMetadata').mockRejectedValue(new Error('metadata exploded') as never);
|
||||||
|
|
||||||
|
const result = await finalize(harness, unitRow());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(harness.metadataService.measureMissingAudioDurations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures nothing when the book rows cannot be written', async () => {
|
||||||
|
const harness = makeService();
|
||||||
|
harness.repo.findUnitFiles.mockResolvedValue(AUDIO_UNIT_FILES);
|
||||||
|
arrange(harness);
|
||||||
|
harness.processor.createUnitBookRecords.mockRejectedValue(new Error('create failed'));
|
||||||
|
|
||||||
|
const result = await finalize(harness, unitRow());
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(harness.metadataService.measureMissingAudioDurations).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('metadata write-back', () => {
|
describe('metadata write-back', () => {
|
||||||
it('schedules a write for every book the unit produced, attributed to the uploader', async () => {
|
it('schedules a write for every book the unit produced, attributed to the uploader', async () => {
|
||||||
const harness = makeService();
|
const harness = makeService();
|
||||||
|
|||||||
@@ -441,6 +441,8 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the metadata, whose duration it supersedes, and before any write-back touches the files.
|
||||||
|
await this.measureAudioDurations(written.bookIds, row.id);
|
||||||
this.processor.reconcileCoversAsync(written.bookIds);
|
this.processor.reconcileCoversAsync(written.bookIds);
|
||||||
await this.cleanupBookDockRecord(row);
|
await this.cleanupBookDockRecord(row);
|
||||||
if (library.fileWriteEnabled) {
|
if (library.fileWriteEnabled) {
|
||||||
@@ -777,6 +779,29 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
|||||||
.join(sep);
|
.join(sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audiobook player derives progress from each audio file's own duration, and nothing else
|
||||||
|
* measures a filed file before the library's next scan; without one, listening stays at 0%.
|
||||||
|
* Never throws: the book is already filed, and a scan repairs whatever this could not measure.
|
||||||
|
*/
|
||||||
|
private async measureAudioDurations(bookIds: number[], fileId: number): Promise<void> {
|
||||||
|
for (const bookId of bookIds) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const { measured, failed } = await this.metadataService.measureMissingAudioDurations(bookId);
|
||||||
|
if (failed > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[book_dock.measure_audio_durations] [end] fileId=${fileId} bookId=${bookId} durationMs=${Date.now() - startedAt} measured=${measured} failed=${failed} - some audio durations could not be measured`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[book_dock.measure_audio_durations] [fail] fileId=${fileId} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - audio duration measurement failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Undoes a part-placed unit. Never throws: it runs while another error is on its way up. */
|
/** Undoes a part-placed unit. Never throws: it runs while another error is on its way up. */
|
||||||
private async undoMoves(moved: Array<{ from: string; to: string }>): Promise<void> {
|
private async undoMoves(moved: Array<{ from: string; to: string }>): Promise<void> {
|
||||||
for (const move of moved) {
|
for (const move of moved) {
|
||||||
|
|||||||
@@ -1865,6 +1865,113 @@ describe('MetadataService', () => {
|
|||||||
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 4321 });
|
expect(updateSet).toHaveBeenCalledWith({ durationSeconds: 4321 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extractAudioFileDuration returns the duration it wrote, or null when the file cannot be read', async () => {
|
||||||
|
const { db } = makeDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
|
||||||
|
mockParseAudioDuration.mockResolvedValueOnce(null).mockResolvedValueOnce(4321);
|
||||||
|
|
||||||
|
await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBeNull();
|
||||||
|
await expect(service.extractAudioFileDuration(99, '/tmp/book.m4b')).resolves.toBe(4321);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('measureMissingAudioDurations', () => {
|
||||||
|
function makeDurationDb(missing: Array<{ absolutePath: string }>) {
|
||||||
|
const selectWhere = vi.fn().mockResolvedValue(missing);
|
||||||
|
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const updateSet = vi.fn().mockReturnValue({ where: updateWhere });
|
||||||
|
const db = {
|
||||||
|
select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }),
|
||||||
|
update: vi.fn().mockReturnValue({ set: updateSet }),
|
||||||
|
};
|
||||||
|
return { db, selectWhere, updateSet, updateWhere };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('looks only at the audio content files of the book that have no positive duration', async () => {
|
||||||
|
const { db, selectWhere } = makeDurationDb([]);
|
||||||
|
const service = makeService(db);
|
||||||
|
|
||||||
|
await service.measureMissingAudioDurations(42);
|
||||||
|
|
||||||
|
const query = new PgDialect().sqlToQuery(selectWhere.mock.calls[0]![0]);
|
||||||
|
expect(query.params).toEqual([42, 'content', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac', 0]);
|
||||||
|
expect(query.sql).toMatch(/"duration_seconds" is null or "book_files"\."duration_seconds" <= \$9/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures each file, then re-totals the book from the durations it wrote', async () => {
|
||||||
|
const { db, updateSet, updateWhere } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]);
|
||||||
|
const service = makeService(db);
|
||||||
|
mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : 900));
|
||||||
|
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 2, failed: 0 });
|
||||||
|
|
||||||
|
expect(mockParseAudioDuration.mock.calls.map((call) => call[0]).sort()).toEqual(['/books/a/01.mp3', '/books/a/02.mp3']);
|
||||||
|
expect(updateSet.mock.calls.map((call) => call[0]).sort((a, b) => a.durationSeconds - b.durationSeconds)).toEqual([
|
||||||
|
{ durationSeconds: 600 },
|
||||||
|
{ durationSeconds: 900 },
|
||||||
|
]);
|
||||||
|
expect(aggregate).toHaveBeenCalledExactlyOnceWith(42);
|
||||||
|
expect(aggregate.mock.invocationCallOrder[0]!).toBeGreaterThan(Math.max(...updateWhere.mock.invocationCallOrder));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the total alone when a file cannot be measured, so the rest cannot shrink it', async () => {
|
||||||
|
const { db, updateSet } = makeDurationDb([{ absolutePath: '/books/a/01.mp3' }, { absolutePath: '/books/a/02.mp3' }]);
|
||||||
|
const service = makeService(db);
|
||||||
|
mockParseAudioDuration.mockImplementation((path) => Promise.resolve(path.endsWith('01.mp3') ? 600 : null));
|
||||||
|
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 1, failed: 1 });
|
||||||
|
|
||||||
|
expect(updateSet).toHaveBeenCalledExactlyOnceWith({ durationSeconds: 600 });
|
||||||
|
expect(aggregate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a duration that rounds to zero as unmeasured', async () => {
|
||||||
|
const { db } = makeDurationDb([{ absolutePath: '/books/a/blip.m4b' }]);
|
||||||
|
const service = makeService(db);
|
||||||
|
mockParseAudioDuration.mockResolvedValue(0);
|
||||||
|
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 1 });
|
||||||
|
|
||||||
|
expect(aggregate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes nothing and keeps the total for a book with no missing durations', async () => {
|
||||||
|
const { db } = makeDurationDb([]);
|
||||||
|
const service = makeService(db);
|
||||||
|
const aggregate = vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 0, failed: 0 });
|
||||||
|
|
||||||
|
expect(mockParseAudioDuration).not.toHaveBeenCalled();
|
||||||
|
expect(db.update).not.toHaveBeenCalled();
|
||||||
|
expect(aggregate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes at most four files at a time', async () => {
|
||||||
|
const tracks = Array.from({ length: 10 }, (_, index) => ({ absolutePath: `/books/a/${String(index + 1).padStart(2, '0')}.mp3` }));
|
||||||
|
const { db } = makeDurationDb(tracks);
|
||||||
|
const service = makeService(db);
|
||||||
|
let inFlight = 0;
|
||||||
|
let peak = 0;
|
||||||
|
mockParseAudioDuration.mockImplementation(async () => {
|
||||||
|
inFlight++;
|
||||||
|
peak = Math.max(peak, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
inFlight--;
|
||||||
|
return 60;
|
||||||
|
});
|
||||||
|
vi.spyOn(service, 'aggregateAudioDuration').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(service.measureMissingAudioDurations(42)).resolves.toEqual({ measured: 10, failed: 0 });
|
||||||
|
|
||||||
|
expect(peak).toBe(4);
|
||||||
|
expect(mockParseAudioDuration).toHaveBeenCalledTimes(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('extractAndAggregateAudioDuration writes the per-file duration before re-aggregating the total', async () => {
|
it('extractAndAggregateAudioDuration writes the per-file duration before re-aggregating the total', async () => {
|
||||||
const { db } = makeDb();
|
const { db } = makeDb();
|
||||||
const service = makeService(db);
|
const service = makeService(db);
|
||||||
@@ -1872,7 +1979,7 @@ describe('MetadataService', () => {
|
|||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const extractSpy = vi.spyOn(service, 'extractAudioFileDuration').mockImplementation(() => {
|
const extractSpy = vi.spyOn(service, 'extractAudioFileDuration').mockImplementation(() => {
|
||||||
callOrder.push('extract');
|
callOrder.push('extract');
|
||||||
return Promise.resolve();
|
return Promise.resolve(null);
|
||||||
});
|
});
|
||||||
const aggregateSpy = vi.spyOn(service, 'aggregateAudioDuration').mockImplementation(() => {
|
const aggregateSpy = vi.spyOn(service, 'aggregateAudioDuration').mockImplementation(() => {
|
||||||
callOrder.push('aggregate');
|
callOrder.push('aggregate');
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
import { DB } from '../../db';
|
import { DB } from '../../db';
|
||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
|
import { mapWithConcurrency } from '../../common/utils/batch.utils';
|
||||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||||
import { isKnownPlaceholderCover } from '../../common/utils/placeholder-cover.utils';
|
import { isKnownPlaceholderCover } from '../../common/utils/placeholder-cover.utils';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ const MAX_PUBLISHED_YEAR = 2200;
|
|||||||
const MAX_COVER_DOWNLOADS = 3;
|
const MAX_COVER_DOWNLOADS = 3;
|
||||||
const MAX_COVER_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
const MAX_COVER_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
||||||
const COVER_DOWNLOAD_TIMEOUT_MS = 15_000;
|
const COVER_DOWNLOAD_TIMEOUT_MS = 15_000;
|
||||||
|
const AUDIO_DURATION_PROBE_CONCURRENCY = 4;
|
||||||
|
|
||||||
/** A fetched cover and how the provider's stated shape fits the slot it is meant for. */
|
/** A fetched cover and how the provider's stated shape fits the slot it is meant for. */
|
||||||
export type CoverDownloadChoice = { url: string; fit?: 'match' | 'unknown' | 'mismatch' };
|
export type CoverDownloadChoice = { url: string; fit?: 'match' | 'unknown' | 'mismatch' };
|
||||||
@@ -441,13 +443,44 @@ export class MetadataService {
|
|||||||
return chaptersReachLastFile(stored, orderedDurationsMs);
|
return chaptersReachLastFile(stored, orderedDurationsMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractAudioFileDuration(bookId: number, absolutePath: string): Promise<void> {
|
async extractAudioFileDuration(bookId: number, absolutePath: string): Promise<number | null> {
|
||||||
const durationSeconds = await parseAudioDuration(absolutePath);
|
const durationSeconds = await parseAudioDuration(absolutePath);
|
||||||
if (durationSeconds === null) return;
|
if (durationSeconds === null) return null;
|
||||||
await this.db
|
await this.db
|
||||||
.update(schema.bookFiles)
|
.update(schema.bookFiles)
|
||||||
.set({ durationSeconds })
|
.set({ durationSeconds })
|
||||||
.where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.absolutePath, absolutePath)));
|
.where(and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.absolutePath, absolutePath)));
|
||||||
|
return durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures the audio files of a book that have no duration yet, then re-totals the book. The
|
||||||
|
* audiobook player derives progress only once every file has a duration, so a book left with a
|
||||||
|
* gap stays at 0%. A file that cannot be measured leaves the total alone: summing only the rest
|
||||||
|
* would shrink it.
|
||||||
|
*/
|
||||||
|
async measureMissingAudioDurations(bookId: number): Promise<{ measured: number; failed: number }> {
|
||||||
|
const files = await this.db
|
||||||
|
.select({ absolutePath: schema.bookFiles.absolutePath })
|
||||||
|
.from(schema.bookFiles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.bookFiles.bookId, bookId),
|
||||||
|
eq(schema.bookFiles.role, 'content'),
|
||||||
|
inArray(schema.bookFiles.format, [...METADATA_AUDIO_FORMATS]),
|
||||||
|
or(isNull(schema.bookFiles.durationSeconds), lte(schema.bookFiles.durationSeconds, 0)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (files.length === 0) return { measured: 0, failed: 0 };
|
||||||
|
|
||||||
|
const outcomes = await mapWithConcurrency(files, AUDIO_DURATION_PROBE_CONCURRENCY, async (file) => {
|
||||||
|
const durationSeconds = await this.extractAudioFileDuration(bookId, file.absolutePath);
|
||||||
|
return durationSeconds !== null && durationSeconds > 0;
|
||||||
|
});
|
||||||
|
const measured = outcomes.filter(Boolean).length;
|
||||||
|
const failed = files.length - measured;
|
||||||
|
if (failed === 0) await this.aggregateAudioDuration(bookId);
|
||||||
|
return { measured, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
async aggregateAudioDuration(bookId: number): Promise<void> {
|
async aggregateAudioDuration(bookId: number): Promise<void> {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
// The audio extractor reads FFPROBE_PATH as it loads, so this has to run ahead of every import below.
|
||||||
|
vi.hoisted(() => {
|
||||||
|
const requireFromHere = process.getBuiltinModule('node:module').createRequire(__filename);
|
||||||
|
process.env.FFPROBE_PATH ??= (requireFromHere('@ffprobe-installer/ffprobe') as { path: string }).path;
|
||||||
|
});
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { mkdir, readFile, realpath, utimes, writeFile } from 'fs/promises';
|
import { mkdir, readFile, realpath, utimes, writeFile } from 'fs/promises';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
@@ -5,10 +12,13 @@ import { afterEach } from 'vitest';
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
Permission,
|
Permission,
|
||||||
|
type AudiobookManifest,
|
||||||
|
type AudiobookPlaybackState,
|
||||||
type BookDockDiscardDuplicatesResult,
|
type BookDockDiscardDuplicatesResult,
|
||||||
type BookDockFile,
|
type BookDockFile,
|
||||||
type BookDockFinalizePreviewResult,
|
type BookDockFinalizePreviewResult,
|
||||||
type BookDockFinalizeResult,
|
type BookDockFinalizeResult,
|
||||||
|
type DashboardScrollerResponse,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
|
|
||||||
import * as schema from '../src/db/schema';
|
import * as schema from '../src/db/schema';
|
||||||
@@ -16,7 +26,7 @@ import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser';
|
|||||||
import { ScannerService } from '../src/modules/scanner/scanner.service';
|
import { ScannerService } from '../src/modules/scanner/scanner.service';
|
||||||
import { waitForCondition } from './e2e/app-harness';
|
import { waitForCondition } from './e2e/app-harness';
|
||||||
import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder';
|
import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder';
|
||||||
import { createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
|
import { createM4bFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
|
||||||
import {
|
import {
|
||||||
authHeader,
|
authHeader,
|
||||||
closeBookDockE2EContext,
|
closeBookDockE2EContext,
|
||||||
@@ -528,6 +538,125 @@ describe('Book Dock ingest + finalize (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue 1757: finalize filed audiobooks with no per-file duration, and the player derives progress
|
||||||
|
* only from those, so listening stayed at 0% and the book never reached Continue Listening until
|
||||||
|
* the library was next scanned. These libraries are not watched, so nothing else measures them.
|
||||||
|
*/
|
||||||
|
describe('listening to a filed audiobook', () => {
|
||||||
|
const AUDIO_SECONDS = 4;
|
||||||
|
const metadata = { title: 'Listening Title', authors: ['Listening Author'] };
|
||||||
|
|
||||||
|
async function finalizeRow(row: { id: number }): Promise<number> {
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/book-dock/finalize',
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: { fileIds: [row.id] },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
const result = (response.json() as BookDockFinalizeResult).results[0];
|
||||||
|
expect(result).toMatchObject({ success: true, bookId: expect.any(Number) });
|
||||||
|
return result!.bookId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dockAudiobook(destination: Awaited<ReturnType<typeof createLibraryWithFolder>>, fileName: string, selected = {}) {
|
||||||
|
const fixture = await createM4bFixture(context.fixture.booksPath, `audio-fixtures/${randomUUID()}.m4b`, {
|
||||||
|
title: metadata.title,
|
||||||
|
durationSeconds: AUDIO_SECONDS,
|
||||||
|
});
|
||||||
|
return createBookDockRow(context, {
|
||||||
|
fileName,
|
||||||
|
content: await readFile(fixture),
|
||||||
|
selectedMetadata: { ...metadata, ...selected },
|
||||||
|
targetLibraryId: destination.libraryId,
|
||||||
|
targetFolderId: destination.libraryFolderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audioDurations(bookId: number) {
|
||||||
|
const files = await context.db
|
||||||
|
.select({ format: schema.bookFiles.format, durationSeconds: schema.bookFiles.durationSeconds })
|
||||||
|
.from(schema.bookFiles)
|
||||||
|
.where(eq(schema.bookFiles.bookId, bookId));
|
||||||
|
const [meta] = await context.db
|
||||||
|
.select({ durationSeconds: schema.bookMetadata.durationSeconds })
|
||||||
|
.from(schema.bookMetadata)
|
||||||
|
.where(eq(schema.bookMetadata.bookId, bookId))
|
||||||
|
.limit(1);
|
||||||
|
return { files: files.filter((file) => file.format === 'm4b').map((file) => file.durationSeconds), book: meta?.durationSeconds ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the web player and the iOS app do: read the manifest, then save a position halfway in. */
|
||||||
|
async function listenHalfway(bookId: number): Promise<AudiobookPlaybackState> {
|
||||||
|
const manifestResponse = await context.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/audiobooks/${bookId}/manifest`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
expect(manifestResponse.statusCode).toBe(200);
|
||||||
|
const manifest = manifestResponse.json() as AudiobookManifest;
|
||||||
|
expect(manifest.assets).toHaveLength(1);
|
||||||
|
expect(manifest.assets[0]!.durationMs).toBe(AUDIO_SECONDS * 1000);
|
||||||
|
expect(manifest.totalDurationMs).toBe(AUDIO_SECONDS * 1000);
|
||||||
|
|
||||||
|
const saveResponse = await context.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/v1/audiobooks/${bookId}/playback-state`,
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
payload: {
|
||||||
|
assetId: manifest.assets[0]!.assetId,
|
||||||
|
positionMs: (AUDIO_SECONDS * 1000) / 2,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
operationId: randomUUID(),
|
||||||
|
baseRevision: 0,
|
||||||
|
manifestRevision: manifest.revision,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveResponse.statusCode).toBe(200);
|
||||||
|
return saveResponse.json() as AudiobookPlaybackState;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continueListeningIds(): Promise<number[]> {
|
||||||
|
const response = await context.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/dashboard/scrollers/continue-listening?limit=50',
|
||||||
|
headers: authHeader(context.adminToken),
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
return (response.json() as DashboardScrollerResponse).books.map((book) => book.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('measures a new audiobook, so listening moves its progress and puts it on Continue Listening', async () => {
|
||||||
|
const destination = await createLibraryWithFolder(context, { allowedFormats: ['m4b'] });
|
||||||
|
// A runtime from a metadata provider: the file's own length replaces it, as a scan would.
|
||||||
|
const row = await dockAudiobook(destination, 'listening-new.m4b', { durationSeconds: 3600 });
|
||||||
|
|
||||||
|
const bookId = await finalizeRow(row);
|
||||||
|
|
||||||
|
expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS });
|
||||||
|
expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false });
|
||||||
|
expect(await continueListeningIds()).toContain(bookId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures an audiobook that joins an ebook already on the shelf', async () => {
|
||||||
|
const destination = await createLibraryWithFolder(context, { allowedFormats: ['epub', 'm4b'] });
|
||||||
|
const ebook = await createBookDockRow(context, {
|
||||||
|
fileName: 'listening-joined.epub',
|
||||||
|
selectedMetadata: metadata,
|
||||||
|
targetLibraryId: destination.libraryId,
|
||||||
|
targetFolderId: destination.libraryFolderId,
|
||||||
|
});
|
||||||
|
const bookId = await finalizeRow(ebook);
|
||||||
|
|
||||||
|
expect(await finalizeRow(await dockAudiobook(destination, 'listening-joined.m4b'))).toBe(bookId);
|
||||||
|
|
||||||
|
expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS });
|
||||||
|
expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false });
|
||||||
|
expect(await continueListeningIds()).toContain(bookId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('finalize writes the dock metadata into the file when the library writes metadata to files', async () => {
|
it('finalize writes the dock metadata into the file when the library writes metadata to files', async () => {
|
||||||
const destination = await createLibraryWithFolder(context, {
|
const destination = await createLibraryWithFolder(context, {
|
||||||
fileWriteEnabled: true,
|
fileWriteEnabled: true,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function bundledFfmpegPaths(): { ffmpeg: string; ffprobe: string } {
|
|||||||
return { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path };
|
return { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createM4bFixture(rootPath: string, relativePath: string, input: { title: string }): Promise<string> {
|
export async function createM4bFixture(rootPath: string, relativePath: string, input: { title: string; durationSeconds?: number }): Promise<string> {
|
||||||
assertRelativePath(relativePath);
|
assertRelativePath(relativePath);
|
||||||
const absolutePath = join(rootPath, relativePath);
|
const absolutePath = join(rootPath, relativePath);
|
||||||
await mkdir(dirname(absolutePath), { recursive: true });
|
await mkdir(dirname(absolutePath), { recursive: true });
|
||||||
@@ -70,7 +70,7 @@ export async function createM4bFixture(rootPath: string, relativePath: string, i
|
|||||||
'-f',
|
'-f',
|
||||||
'lavfi',
|
'lavfi',
|
||||||
'-i',
|
'-i',
|
||||||
'sine=frequency=440:sample_rate=8000:duration=1',
|
`sine=frequency=440:sample_rate=8000:duration=${input.durationSeconds ?? 1}`,
|
||||||
'-c:a',
|
'-c:a',
|
||||||
'aac',
|
'aac',
|
||||||
'-b:a',
|
'-b:a',
|
||||||
|
|||||||
Reference in New Issue
Block a user