diff --git a/server/src/common/utils/book-media.utils.ts b/server/src/common/utils/book-media.utils.ts index 0c839ba68..cc8f21287 100644 --- a/server/src/common/utils/book-media.utils.ts +++ b/server/src/common/utils/book-media.utils.ts @@ -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)); +} diff --git a/server/src/modules/audiobook/audiobook.service.ts b/server/src/modules/audiobook/audiobook.service.ts index 95021a6bb..5eb3b67e8 100644 --- a/server/src/modules/audiobook/audiobook.service.ts +++ b/server/src/modules/audiobook/audiobook.service.ts @@ -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 { 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') diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index 626f4e4c9..c270937d3 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -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, diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index b49c3dbab..70456a558 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -4349,6 +4349,76 @@ describe('BookService', () => { ); }); + function mockAudiobookDetail( + bookRepo: ReturnType['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(); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 4820ba6f7..ab4e307be 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -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;