fix(books): order audiobook files naturally

Return audiobook files in filename order when persisted sort values are stale so every client starts playback with the first part.

Fixes #1109
This commit is contained in:
neon
2026-08-24 20:18:54 +00:00
parent 2a280ad106
commit 691dfd94f4
5 changed files with 57 additions and 35 deletions
@@ -0,0 +1,15 @@
import { naturalCompare } from './natural-sort.utils';
describe('naturalCompare', () => {
it('orders numeric filename parts by value', () => {
const filenames = ['Part 10.mp3', 'Part 02.mp3', 'Part 01.mp3'];
expect(filenames.sort(naturalCompare)).toEqual(['Part 01.mp3', 'Part 02.mp3', 'Part 10.mp3']);
});
it('orders every numeric run in a filename', () => {
const filenames = ['Disc 2 - Track 10.mp3', 'Disc 10 - Track 1.mp3', 'Disc 2 - Track 2.mp3'];
expect(filenames.sort(naturalCompare)).toEqual(['Disc 2 - Track 2.mp3', 'Disc 2 - Track 10.mp3', 'Disc 10 - Track 1.mp3']);
});
});
@@ -0,0 +1,20 @@
export function naturalCompare(a: string, b: string): number {
const numericRun = /(\d+)/;
const aParts = a.split(numericRun);
const bParts = b.split(numericRun);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? '';
const bPart = bParts[i] ?? '';
if (/^\d+$/.test(aPart) && /^\d+$/.test(bPart)) {
const difference = Number.parseInt(aPart, 10) - Number.parseInt(bPart, 10);
if (difference !== 0) return difference;
} else {
const difference = aPart.localeCompare(bPart);
if (difference !== 0) return difference;
}
}
return 0;
}
+14 -13
View File
@@ -3917,7 +3917,7 @@ describe('BookService', () => {
await expect(service.getDetail(9, user)).rejects.toThrow(NotFoundException);
});
it('maps detail payload and synthesizes audiobook chapters from file durations', async () => {
it('naturally orders stale audiobook file rows before mapping files and synthesizing chapters', async () => {
const { service, bookRepo, userBookStatusService, comicMetadataService, fileWriteService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
@@ -3982,24 +3982,24 @@ describe('BookService', () => {
genreRows: [{ name: 'Sci-Fi' }],
tagRows: [{ name: 'classic' }],
fileRows: [
{
id: 100,
format: 'mp3',
role: 'content',
sizeBytes: 10,
absolutePath: '/audio/01-intro.mp3',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
durationSeconds: 30,
},
{
id: 101,
format: 'm4b',
role: 'content',
sizeBytes: 20,
absolutePath: '/audio/02-main.m4b',
absolutePath: '/audio/Part 02.m4b',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
durationSeconds: 60,
},
{
id: 100,
format: 'mp3',
role: 'content',
sizeBytes: 10,
absolutePath: '/audio/Part 01.mp3',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
durationSeconds: 30,
},
],
narratorRows: [{ id: 4, name: 'Narrator Name', sortName: null, displayOrder: 0 }],
communityRatingRows: [
@@ -4032,9 +4032,10 @@ describe('BookService', () => {
{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-24T00:00:00.000Z' },
]);
expect(result.audioMetadata?.chapters).toEqual([
{ title: '01-intro', startMs: 0 },
{ title: '02-main', startMs: 30_000 },
{ title: 'Part 01', startMs: 0 },
{ title: 'Part 02', startMs: 30_000 },
]);
expect(result.files.map((file) => file.filename)).toEqual(['Part 01.mp3', 'Part 02.m4b']);
expect(result.readStatus).toEqual({
status: 'reading',
source: 'manual',
+7 -3
View File
@@ -18,6 +18,7 @@ import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../
import { resolveIsAudiobook } from '../../common/utils/book-media.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils';
import { naturalCompare } from '../../common/utils/natural-sort.utils';
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
import { formatSeriesIndex } from '../../common/utils/series-index-format.utils';
import { SeriesExpectedCountService } from '../../common/services/series-expected-count.service';
@@ -3038,7 +3039,10 @@ export class BookService {
const meta = book.book_metadata;
const customMetadata = await this.customMetadataService.getBookValues(id, book.books.libraryId);
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, fileRows);
const orderedFileRows = hasAudioFiles
? [...fileRows].sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)))
: fileRows;
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, orderedFileRows);
const supplementalFields = buildBookDetailSupplementalFields({
readStatus,
hasAudioFiles,
@@ -3099,7 +3103,7 @@ export class BookService {
authors: authorRows,
genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name),
files: fileRows.map((f) => ({
files: orderedFileRows.map((f) => ({
id: f.id,
format: f.format,
role: f.id === book.books.primaryFileId ? 'primary' : f.role,
@@ -3113,7 +3117,7 @@ export class BookService {
metadataScore: meta?.metadataScore ?? null,
formatPriority: (book.libraries?.formatPriority as string[] | null) ?? [],
customMetadata,
fileWriteStatus: this.fileWriteService?.resolveBookFileWriteStatus(book.libraries, fileRows, book.books.primaryFileId) ?? {
fileWriteStatus: this.fileWriteService?.resolveBookFileWriteStatus(book.libraries, orderedFileRows, book.books.primaryFileId) ?? {
enabled: false,
reason: 'library_disabled',
writableFormats: [],
+1 -19
View File
@@ -1,6 +1,7 @@
import { readdir, stat } from 'fs/promises';
import { basename, dirname, join, relative } from 'path';
import { naturalCompare } from '../../../common/utils/natural-sort.utils';
import { classifyFile, isPrimaryFormat, isAudioFormat, type FileRole } from './classify';
export interface FileStat {
@@ -42,25 +43,6 @@ function stemOf(name: string): string {
return i > 0 ? name.slice(0, i) : name;
}
// Natural sort: splits on numeric runs so "Chapter 10" sorts after "Chapter 9"
function naturalCompare(a: string, b: string): number {
const re = /(\d+)/;
const aParts = a.split(re);
const bParts = b.split(re);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const ap = aParts[i] ?? '';
const bp = bParts[i] ?? '';
if (/^\d+$/.test(ap) && /^\d+$/.test(bp)) {
const diff = parseInt(ap, 10) - parseInt(bp, 10);
if (diff !== 0) return diff;
} else {
const diff = ap.localeCompare(bp);
if (diff !== 0) return diff;
}
}
return 0;
}
function buildExcludeMatcher(patterns: string[]): (name: string) => boolean {
if (patterns.length === 0) return () => false;
const compiled = patterns.map((p) => {