mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): stop synthesizing audiobook chapters from unknown durations
A multi-track audiobook without stored chapters gets one synthesized chapter per track, placed at the running total of track durations. An unknown duration counted as zero, so every later chapter started at the wrong offset, often all at 0, and the manifest then mapped them all onto the last asset. The synthesis also walked the tracks in file name order while the manifest plays them by sort order first, so chapter titles could disagree with the track they pointed at. Skip the synthesis when any track duration is unknown, as already happens for a single track; clients fall back to one entry per track. Order the synthesized chapters with the same comparator the manifest uses, now shared from book-media utils. The detail files list keeps its existing order. Affected books get a new manifest revision because the revision hashes the chapters.
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { getBookMediaProfile, type CoverMedia, type CoverMedium } from '@bookorbit/types';
|
||||
|
||||
import { naturalCompare } from './natural-sort.utils';
|
||||
|
||||
type BookMediaFileRow = { format: string | null; role: string };
|
||||
|
||||
/** What a metadata fetch needs to know about a book's cover slots. */
|
||||
@@ -35,3 +39,12 @@ export function coverFetchInputs(state: CoverFetchState): {
|
||||
options: { coverMedia: state.media, lockedCoverSlots: state.locked },
|
||||
};
|
||||
}
|
||||
|
||||
type AudioTrackOrderRow = { sortOrder?: number | null; absolutePath: string };
|
||||
|
||||
/** Playback order of an audiobook's tracks: explicit sort order first, unset last, then natural file name. */
|
||||
export function compareAudioTracks(left: AudioTrackOrderRow, right: AudioTrackOrderRow): number {
|
||||
const byOrder = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
if (byOrder !== 0) return byOrder;
|
||||
return naturalCompare(basename(left.absolutePath), basename(right.absolutePath));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { naturalCompare } from '../../common/utils/natural-sort.utils';
|
||||
import { compareAudioTracks } from '../../common/utils/book-media.utils';
|
||||
import type { BookmarkRow } from '../../db/schema';
|
||||
import { BookService } from '../book/book.service';
|
||||
import type { CreateAudiobookBookmarkDto } from './dto/create-audiobook-bookmark.dto';
|
||||
@@ -199,15 +199,7 @@ export class AudiobookService {
|
||||
private async loadManifestContext(bookId: number, user: RequestUser): Promise<ManifestContext> {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
const [detail, rows] = await Promise.all([this.bookService.getDetail(bookId, user), this.repo.findAudioFiles(bookId)]);
|
||||
const files = rows
|
||||
.filter((row) => row.format !== null && isAudioFormat(row.format))
|
||||
.sort((left, right) => {
|
||||
if (left.sortOrder !== null || right.sortOrder !== null) {
|
||||
const byOrder = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
|
||||
if (byOrder !== 0) return byOrder;
|
||||
}
|
||||
return naturalCompare(basename(left.absolutePath), basename(right.absolutePath));
|
||||
});
|
||||
const files = rows.filter((row) => row.format !== null && isAudioFormat(row.format)).sort(compareAudioTracks);
|
||||
if (files.length === 0) throw new NotFoundException('Book has no audiobook assets');
|
||||
|
||||
const revision = createHash('sha256')
|
||||
|
||||
@@ -1458,6 +1458,7 @@ export class BookRepository {
|
||||
absolutePath: bookFiles.absolutePath,
|
||||
createdAt: bookFiles.createdAt,
|
||||
durationSeconds: bookFiles.durationSeconds,
|
||||
sortOrder: bookFiles.sortOrder,
|
||||
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
|
||||
mediaOverlayDurationSeconds: bookFiles.mediaOverlayDurationSeconds,
|
||||
mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt,
|
||||
|
||||
@@ -4349,6 +4349,76 @@ describe('BookService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
function mockAudiobookDetail(
|
||||
bookRepo: ReturnType<typeof makeService>['bookRepo'],
|
||||
fileRows: { id: number; absolutePath: string; durationSeconds: number | null; sortOrder: number | null }[],
|
||||
) {
|
||||
bookRepo.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: {
|
||||
id: 21,
|
||||
libraryId: 3,
|
||||
primaryFileId: fileRows[0]!.id,
|
||||
status: 'present',
|
||||
folderPath: '/audio',
|
||||
addedAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
},
|
||||
libraries: { name: 'Main', formatPriority: null },
|
||||
book_metadata: { title: 'Tracks', chapters: null, durationSeconds: null },
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
tagRows: [],
|
||||
fileRows: fileRows.map((file) => ({
|
||||
...file,
|
||||
format: 'mp3',
|
||||
role: 'content',
|
||||
sizeBytes: 10,
|
||||
createdAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
})),
|
||||
narratorRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
|
||||
bookRepo.findRatingByBookAndUser.mockResolvedValue(null);
|
||||
}
|
||||
|
||||
it('synthesizes chapters in manifest track order while the file list keeps name order', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
mockAudiobookDetail(bookRepo, [
|
||||
{ id: 1, absolutePath: '/audio/Part 01.mp3', durationSeconds: 30, sortOrder: 2 },
|
||||
{ id: 2, absolutePath: '/audio/Part 02.mp3', durationSeconds: 60, sortOrder: 1 },
|
||||
{ id: 3, absolutePath: '/audio/Bonus.mp3', durationSeconds: 10, sortOrder: null },
|
||||
]);
|
||||
|
||||
const result = await service.getDetail(21, makeUser());
|
||||
|
||||
expect(result.audioMetadata?.chapters).toEqual([
|
||||
{ title: 'Part 02', startMs: 0 },
|
||||
{ title: 'Part 01', startMs: 60_000 },
|
||||
{ title: 'Bonus', startMs: 90_000 },
|
||||
]);
|
||||
expect(result.files.map((file) => file.filename)).toEqual(['Bonus.mp3', 'Part 01.mp3', 'Part 02.mp3']);
|
||||
});
|
||||
|
||||
it('does not synthesize chapters while any track duration is unknown', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
|
||||
for (const unknown of [null, 0]) {
|
||||
mockAudiobookDetail(bookRepo, [
|
||||
{ id: 1, absolutePath: '/audio/Part 01.mp3', durationSeconds: 30, sortOrder: null },
|
||||
{ id: 2, absolutePath: '/audio/Part 02.mp3', durationSeconds: unknown, sortOrder: null },
|
||||
{ id: 3, absolutePath: '/audio/Part 03.mp3', durationSeconds: 20, sortOrder: null },
|
||||
]);
|
||||
|
||||
const result = await service.getDetail(21, makeUser());
|
||||
|
||||
expect(result.audioMetadata?.chapters).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves null personal detail fields when no user-specific state exists', async () => {
|
||||
const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
@@ -14,7 +14,7 @@ import { mkdtemp, rm, stat, rename } from 'fs/promises';
|
||||
import { inArray, type SQL } from 'drizzle-orm';
|
||||
|
||||
import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
import { coverFetchInputs, resolveIsAudiobook } from '../../common/utils/book-media.utils';
|
||||
import { compareAudioTracks, coverFetchInputs, resolveIsAudiobook } from '../../common/utils/book-media.utils';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils';
|
||||
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
|
||||
@@ -3667,12 +3667,14 @@ export class BookService {
|
||||
|
||||
private resolveChapters(
|
||||
stored: AudiobookChapter[] | null | undefined,
|
||||
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null }[],
|
||||
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null; sortOrder?: number | null }[],
|
||||
): AudiobookChapter[] | null {
|
||||
if (stored && stored.length > 0) return stored;
|
||||
|
||||
const audioFiles = fileRows.filter((f) => f.format && isAudioFormat(f.format));
|
||||
const audioFiles = fileRows.filter((f) => f.format && isAudioFormat(f.format)).sort(compareAudioTracks);
|
||||
if (audioFiles.length < 2) return stored ?? null;
|
||||
// Offsets past a track of unknown length would be wrong; clients fall back to one entry per track.
|
||||
if (audioFiles.some((f) => f.durationSeconds === null || f.durationSeconds <= 0)) return stored ?? null;
|
||||
|
||||
const chapters: AudiobookChapter[] = [];
|
||||
let offsetMs = 0;
|
||||
|
||||
Reference in New Issue
Block a user