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