From 688cfdeaecfe0c647cb154a7a46d4235709ebc76 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:05:13 +0000 Subject: [PATCH] fix(scanner): keep audiobook tracks in file name order The player follows each track's stored sort order, which overlapping folder scans, Add File, and imports joining a book could leave out of step with the file names the Files tab shows. Realign a book's tracks after each of those writes, under the book row lock. Closes #1681 --- scripts/e2e/suite-registry.mjs | 23 +++ .../src/common/utils/book-media.utils.test.ts | 123 +++++++++++- server/src/common/utils/book-media.utils.ts | 42 ++++- server/src/db/audio-track-order.test.ts | 92 +++++++++ server/src/db/audio-track-order.ts | 45 +++++ .../scanner/scanner.repository.test.ts | 31 +++ .../src/modules/scanner/scanner.repository.ts | 5 + .../modules/scanner/scanner.service.test.ts | 135 +++++++++++++ server/src/modules/scanner/scanner.service.ts | 26 +++ .../upload/upload-processor.service.test.ts | 59 ++++++ .../upload/upload-processor.service.ts | 9 + .../src/modules/upload/upload.service.test.ts | 23 +++ server/src/modules/upload/upload.service.ts | 3 + server/test/audiobook-track-order.e2e-spec.ts | 177 ++++++++++++++++++ server/test/e2e/app-harness.ts | 7 +- 15 files changed, 796 insertions(+), 4 deletions(-) create mode 100644 server/src/db/audio-track-order.test.ts create mode 100644 server/src/db/audio-track-order.ts create mode 100644 server/test/audiobook-track-order.e2e-spec.ts diff --git a/scripts/e2e/suite-registry.mjs b/scripts/e2e/suite-registry.mjs index f7c43d610..85f9e496c 100644 --- a/scripts/e2e/suite-registry.mjs +++ b/scripts/e2e/suite-registry.mjs @@ -101,6 +101,29 @@ export const E2E_SUITES = Object.freeze({ ...SHARED_DB_AND_HELPER_PATHS, ], }, + "audiobook-track-order": { + id: "audiobook-track-order", + name: "Audiobook Track Order", + timeout: 45, + lane: "full", + description: "Audiobook playback order matches file names after overlapping scans and added tracks", + vitestTarget: "test/audiobook-track-order.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/audiobook-track-order-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + changedPaths: [ + "server/src/common/utils/audio-track-order.utils.ts", + "server/src/common/utils/book-media.utils.ts", + "server/src/db/audio-track-order.ts", + "server/src/modules/audiobook/**", + "server/src/modules/scanner/**", + "server/src/modules/upload/**", + "server/test/audiobook-track-order.e2e-spec.ts", + "server/test/e2e/scanner/**", + ".github/workflows/e2e-runner.yml", + ...SHARED_DB_AND_HELPER_PATHS, + ], + }, "auth-session-security": { id: "auth-session-security", name: "Auth Session Security", diff --git a/server/src/common/utils/book-media.utils.test.ts b/server/src/common/utils/book-media.utils.test.ts index 5cef0a984..49b035a78 100644 --- a/server/src/common/utils/book-media.utils.test.ts +++ b/server/src/common/utils/book-media.utils.test.ts @@ -1,4 +1,6 @@ -import { resolveIsAudiobook } from './book-media.utils'; +import { isAudioFormat } from '@bookorbit/types'; + +import { compareAudioTracks, planAudioTrackOrderRepair, resolveIsAudiobook } from './book-media.utils'; describe('resolveIsAudiobook', () => { it('uses the primary file when the book has files', () => { @@ -36,3 +38,122 @@ describe('resolveIsAudiobook', () => { expect(resolveIsAudiobook(undefined, null)).toBe(false); }); }); + +describe('planAudioTrackOrderRepair', () => { + type Row = { id: number; absolutePath: string; format: string | null; sortOrder: number | null }; + + const track = (id: number, name: string, sortOrder: number | null, format: string | null = 'mp3'): Row => ({ + id, + absolutePath: `/library/Andy Weir/Project Hail Mary (2021)/${name}`, + format, + sortOrder, + }); + + /** Plays the rows the way the audiobook manifest does once the plan is applied. */ + function playbackAfter(rows: Row[]): string[] { + const updates = new Map(planAudioTrackOrderRepair(rows).map((update) => [update.id, update.sortOrder])); + return rows + .map((row) => ({ ...row, sortOrder: updates.get(row.id) ?? row.sortOrder })) + .filter((row) => row.format !== null && isAudioFormat(row.format)) + .sort(compareAudioTracks) + .map((row) => row.absolutePath.split('/').pop()!); + } + + it('leaves a book alone when its tracks already play in path order', () => { + const rows = [track(1, 'cover.jpg', 0, 'jpg'), track(2, 'Part 1.mp3', 1), track(3, 'Part 2.mp3', 2), track(4, 'Part 10.mp3', 3)]; + + expect(planAudioTrackOrderRepair(rows)).toEqual([]); + }); + + it('leaves gaps in the numbering alone, since only the order matters', () => { + const rows = [track(1, 'Part 1.mp3', 0), track(2, 'Part 2.mp3', 7), track(3, 'Part 3.mp3', 40)]; + + expect(planAudioTrackOrderRepair(rows)).toEqual([]); + }); + + it('ignores books with fewer than two tracks', () => { + expect(planAudioTrackOrderRepair([])).toEqual([]); + expect(planAudioTrackOrderRepair([track(1, 'book.m4b', null, 'm4b'), track(2, 'book.epub', 5, 'epub')])).toEqual([]); + }); + + it('repairs the order reported in issue 1681, where two scans numbered the folder from different listings', () => { + const extras = ['author.jpg', 'back.jpg', 'cover.jpg', 'description.txt', 'folder.jpg', 'info.txt', 'narrator.txt']; + const rows: Row[] = extras.map((name, index) => track(100 + index, name, index, name.endsWith('.jpg') ? 'jpg' : 'txt')); + // A scan of the whole folder numbered 01 and 02 after the seven extras; one that listed the + // folder before 01, 02 and the extras arrived finished last and numbered 03 to 32 from zero. + rows.push(track(1, 'Project Hail Mary (01).mp3', 7), track(2, 'Project Hail Mary (02).mp3', 8)); + for (let number = 3; number <= 32; number++) { + rows.push(track(number, `Project Hail Mary (${String(number).padStart(2, '0')}).mp3`, number - 3)); + } + + expect(playbackAfter(rows)).toEqual(Array.from({ length: 32 }, (_, index) => `Project Hail Mary (${String(index + 1).padStart(2, '0')}).mp3`)); + // Each track takes the number a scan of the whole folder gives it, so the next scan writes nothing. + const updates = new Map(planAudioTrackOrderRepair(rows).map((update) => [update.id, update.sortOrder])); + expect(updates.has(1)).toBe(false); + expect(updates.has(2)).toBe(false); + expect(updates.get(3)).toBe(9); + expect(updates.get(32)).toBe(38); + }); + + it('places a track added without a number where its name puts it', () => { + const rows = [track(1, 'Part 01.mp3', 0), track(2, 'Part 02.mp3', 1), track(3, 'Part 04.mp3', 2), track(4, 'Part 03.mp3', null)]; + + expect(playbackAfter(rows)).toEqual(['Part 01.mp3', 'Part 02.mp3', 'Part 03.mp3', 'Part 04.mp3']); + expect(planAudioTrackOrderRepair(rows)).toEqual([ + { id: 3, sortOrder: 3 }, + { id: 4, sortOrder: 2 }, + ]); + }); + + it('renumbers tracks an import numbered from zero after joining a book that already had tracks', () => { + const rows = [track(1, 'Part 01.mp3', 0), track(2, 'Part 02.mp3', 1), track(3, 'Part 03.mp3', 0), track(4, 'Part 04.mp3', 1)]; + + expect(playbackAfter(rows)).toEqual(['Part 01.mp3', 'Part 02.mp3', 'Part 03.mp3', 'Part 04.mp3']); + }); + + it('separates tracks that share a number, which would otherwise play in whatever order the rows load', () => { + const rows = [track(1, 'Part 1.mp3', 0), track(2, 'Part 2.mp3', 0)]; + + expect(planAudioTrackOrderRepair(rows)).toEqual([{ id: 2, sortOrder: 1 }]); + }); + + it('keeps disc folders playing disc by disc', () => { + const rows = [track(1, 'CD 1/01.mp3', 2), track(2, 'CD 1/02.mp3', 3), track(3, 'CD 2/01.mp3', 0), track(4, 'CD 2/02.mp3', 1)]; + + expect(playbackAfter(rows)).toEqual(['01.mp3', '02.mp3', '01.mp3', '02.mp3']); + expect(planAudioTrackOrderRepair(rows)).toEqual([ + { id: 1, sortOrder: 0 }, + { id: 2, sortOrder: 1 }, + { id: 3, sortOrder: 2 }, + { id: 4, sortOrder: 3 }, + ]); + }); + + it('accepts names natural order cannot tell apart in either order, as a scan leaves them', () => { + expect(planAudioTrackOrderRepair([track(1, 'track1.mp3', 0), track(2, 'track01.mp3', 1)])).toEqual([]); + expect(planAudioTrackOrderRepair([track(1, 'track1.mp3', 1), track(2, 'track01.mp3', 0)])).toEqual([]); + }); + + it('numbers names natural order cannot tell apart the same way every time', () => { + const rows = [track(1, 'track1.mp3', null), track(2, 'track01.mp3', null)]; + + expect(planAudioTrackOrderRepair(rows)).toEqual(planAudioTrackOrderRepair([...rows].reverse()).reverse()); + expect(planAudioTrackOrderRepair(rows)).toEqual([ + { id: 1, sortOrder: 1 }, + { id: 2, sortOrder: 0 }, + ]); + }); + + it('never renumbers files that are not tracks', () => { + const rows = [track(1, 'cover.jpg', null, 'jpg'), track(2, 'Part 2.mp3', 0), track(3, 'Part 1.mp3', 1), track(4, 'notes.nfo', null, null)]; + const updatedIds = planAudioTrackOrderRepair(rows).map((update) => update.id); + + expect(updatedIds.sort()).toEqual([2, 3]); + }); + + it('treats audio formats case-insensitively, as the player does', () => { + const rows = [track(1, 'Part 1.MP3', 1, 'MP3'), track(2, 'Part 2.MP3', 0, 'MP3')]; + + expect(playbackAfter(rows)).toEqual(['Part 1.MP3', 'Part 2.MP3']); + }); +}); diff --git a/server/src/common/utils/book-media.utils.ts b/server/src/common/utils/book-media.utils.ts index d6027f23b..fb4a5b3c4 100644 --- a/server/src/common/utils/book-media.utils.ts +++ b/server/src/common/utils/book-media.utils.ts @@ -1,4 +1,4 @@ -import { getBookMediaKind, getBookMediaProfile, type CoverMedia, type CoverMedium } from '@bookorbit/types'; +import { getBookMediaKind, getBookMediaProfile, isAudioFormat, type CoverMedia, type CoverMedium } from '@bookorbit/types'; import { compareBookFilePaths } from './audio-track-order.utils'; @@ -55,3 +55,43 @@ export function compareAudioTracks(left: AudioTrackOrderRow, right: AudioTrackOr if (byOrder !== 0) return byOrder; return compareBookFilePaths(left.absolutePath, right.absolutePath); } + +export type TrackOrderFileRow = { id: number; absolutePath: string; format: string | null; sortOrder: number | null }; + +/** + * The sort orders that put a book's audio tracks back in natural path order, which is the order + * the Files tab shows and the one a scan of the whole folder writes. Each writer numbers only the + * files it handles: an upload that adds a track leaves it unset, an import that joins a book + * numbers its own files from zero, and a scan that started before newer files arrived numbers the + * folder as it was. The player follows the stored numbers, so any of them can scramble playback. + * + * A book is left alone while every track has its own number and the numbers rise in path order. + * Two names natural order cannot tell apart, such as `track1` and `track01`, may come in either + * order, as a scan leaves them. Otherwise each track takes its rank among all of the book's files + * by path, which is what a scan writes, so the next scan finds nothing to change. Returns only the + * tracks whose number changes. + */ +export function planAudioTrackOrderRepair(files: readonly TrackOrderFileRow[]): Array<{ id: number; sortOrder: number }> { + const tracks = files.filter((file) => file.format !== null && isAudioFormat(file.format)); + if (tracks.length < 2 || playsInPathOrder(tracks)) return []; + + const rankById = new Map([...files].sort(compareFilesByPath).map((file, rank) => [file.id, rank])); + return tracks.filter((track) => track.sortOrder !== rankById.get(track.id)).map((track) => ({ id: track.id, sortOrder: rankById.get(track.id)! })); +} + +function playsInPathOrder(tracks: readonly TrackOrderFileRow[]): boolean { + if (tracks.some((track) => track.sortOrder === null)) return false; + const stored = [...tracks].sort((left, right) => left.sortOrder! - right.sortOrder!); + return stored.every((track, index) => { + const previous = stored[index - 1]; + return !previous || (track.sortOrder! > previous.sortOrder! && compareBookFilePaths(previous.absolutePath, track.absolutePath) <= 0); + }); +} + +/** Natural path order, made total so a repair numbers the same files the same way every time. */ +function compareFilesByPath(left: TrackOrderFileRow, right: TrackOrderFileRow): number { + return ( + compareBookFilePaths(left.absolutePath, right.absolutePath) || + (left.absolutePath < right.absolutePath ? -1 : left.absolutePath > right.absolutePath ? 1 : 0) + ); +} diff --git a/server/src/db/audio-track-order.test.ts b/server/src/db/audio-track-order.test.ts new file mode 100644 index 000000000..638617d47 --- /dev/null +++ b/server/src/db/audio-track-order.test.ts @@ -0,0 +1,92 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; + +import type { CapturedQuery } from '../common/test-utils/capture-sql-db'; +import { realignAudioTrackOrder } from './audio-track-order'; +import * as schema from './schema'; + +type FileRow = [id: number, absolutePath: string, format: string | null, sortOrder: number | null]; + +/** Records the SQL Postgres would receive and answers the read of the book's files with `files`. */ +function createDb(files: FileRow[]) { + const queries: CapturedQuery[] = []; + const client = { + query: (query: string | { text: string; values?: unknown[] }, params?: unknown[]) => { + const text = typeof query === 'string' ? query : query.text; + queries.push({ sql: text, params: params ?? (typeof query === 'string' ? [] : (query.values ?? [])) }); + const readsFiles = text.startsWith('select') && text.includes('from "book_files"'); + return Promise.resolve({ rows: readsFiles ? files : [], fields: [], rowCount: 0 }); + }, + }; + return { db: drizzle(client as never, { schema }), queries }; +} + +const folder = '/library/Andy Weir/Project Hail Mary (2021)'; + +describe('realignAudioTrackOrder', () => { + it('locks the book before reading its files, so concurrent writers renumber one at a time', async () => { + const { db, queries } = createDb([]); + + await realignAudioTrackOrder(db, 7); + + expect(queries[0]!.sql).toMatch(/^select .* from "books" where "books"\."id" = \$1 for update$/); + expect(queries[0]!.params).toEqual([7]); + expect(queries[1]!.sql).toMatch(/from "book_files" where "book_files"\."book_id" = \$1$/); + expect(queries[1]!.params).toEqual([7]); + }); + + it('writes nothing while the tracks already play in path order', async () => { + const { db, queries } = createDb([ + [1, `${folder}/cover.jpg`, 'jpg', 0], + [2, `${folder}/Project Hail Mary (01).mp3`, 'mp3', 1], + [3, `${folder}/Project Hail Mary (02).mp3`, 'mp3', 2], + ]); + + await expect(realignAudioTrackOrder(db, 7)).resolves.toBe(0); + expect(queries.some((query) => query.sql.startsWith('update'))).toBe(false); + }); + + it('renumbers out-of-order tracks of that book in one statement and marks them updated', async () => { + const { db, queries } = createDb([ + [11, `${folder}/Project Hail Mary (01).mp3`, 'mp3', 7], + [12, `${folder}/Project Hail Mary (02).mp3`, 'mp3', 8], + [13, `${folder}/Project Hail Mary (03).mp3`, 'mp3', 0], + [14, `${folder}/Project Hail Mary (04).mp3`, 'mp3', 1], + ]); + + await expect(realignAudioTrackOrder(db, 7)).resolves.toBe(4); + + const updates = queries.filter((query) => query.sql.startsWith('update')); + expect(updates).toHaveLength(1); + const [update] = updates; + expect(update!.sql).toContain('update "book_files" set "sort_order" = case "book_files"."id" when $1 then $2::integer'); + // The audiobook manifest revision hashes each file's updated_at, so players reload the new order. + expect(update!.sql).toContain('"updated_at" ='); + expect(update!.sql).toMatch(/where \("book_files"\."book_id" = \$\d+ and "book_files"\."id" in \(\$\d+, \$\d+, \$\d+, \$\d+\)\)$/); + expect(update!.params.slice(0, 8)).toEqual([11, 0, 12, 1, 13, 2, 14, 3]); + expect(update!.params).toContain(7); + }); + + it('propagates a failed write so the caller rolls the whole transaction back', async () => { + const failure = new Error('could not serialize access'); + const db = drizzle( + { + query: (query: string | { text: string }) => + (typeof query === 'string' ? query : query.text).startsWith('update') + ? Promise.reject(failure) + : Promise.resolve({ + rows: (typeof query === 'string' ? query : query.text).includes('from "book_files"') + ? [ + [1, `${folder}/b.mp3`, 'mp3', 0], + [2, `${folder}/a.mp3`, 'mp3', 1], + ] + : [], + fields: [], + rowCount: 0, + }), + } as never, + { schema }, + ); + + await expect(realignAudioTrackOrder(db, 7)).rejects.toMatchObject({ cause: failure }); + }); +}); diff --git a/server/src/db/audio-track-order.ts b/server/src/db/audio-track-order.ts new file mode 100644 index 000000000..1e62976b5 --- /dev/null +++ b/server/src/db/audio-track-order.ts @@ -0,0 +1,45 @@ +import { and, eq, inArray, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { planAudioTrackOrderRepair } from '../common/utils/book-media.utils'; +import * as schema from './schema'; + +/** + * Puts a book's stored audio track order back in natural path order after a write that numbered + * only some of its files, and returns how many tracks it renumbered. Call it inside the writer's + * transaction: it takes the book row lock, so writers serialize on it and the one that finishes + * last numbers every row the others left behind. + */ +export async function realignAudioTrackOrder(tx: Pick, 'select' | 'update'>, bookId: number): Promise { + await tx.select({ id: schema.books.id }).from(schema.books).where(eq(schema.books.id, bookId)).for('update'); + const files = await tx + .select({ + id: schema.bookFiles.id, + absolutePath: schema.bookFiles.absolutePath, + format: schema.bookFiles.format, + sortOrder: schema.bookFiles.sortOrder, + }) + .from(schema.bookFiles) + .where(eq(schema.bookFiles.bookId, bookId)); + + const changes = planAudioTrackOrderRepair(files); + if (changes.length === 0) return 0; + + const sortOrder = sql.join( + [sql`case ${schema.bookFiles.id}`, ...changes.map((change) => sql`when ${change.id} then ${change.sortOrder}::integer`), sql`end`], + sql` `, + ); + await tx + .update(schema.bookFiles) + .set({ sortOrder }) + .where( + and( + eq(schema.bookFiles.bookId, bookId), + inArray( + schema.bookFiles.id, + changes.map((change) => change.id), + ), + ), + ); + return changes.length; +} diff --git a/server/src/modules/scanner/scanner.repository.test.ts b/server/src/modules/scanner/scanner.repository.test.ts index 75c5cbfa7..cf36219f3 100644 --- a/server/src/modules/scanner/scanner.repository.test.ts +++ b/server/src/modules/scanner/scanner.repository.test.ts @@ -445,6 +445,37 @@ describe('ScannerRepository', () => { expect(db.selectDistinctOn).not.toHaveBeenCalled(); }); + describe('realignAudioTrackOrder', () => { + it('renumbers the tracks inside a transaction that holds the book row', async () => { + const { repo, queues, db, chains } = makeRepo(); + queues.select.push([{ id: 4 }]); + queues.select.push([ + { id: 41, absolutePath: '/books/Book/Part 2.mp3', format: 'mp3', sortOrder: 0 }, + { id: 42, absolutePath: '/books/Book/Part 1.mp3', format: 'mp3', sortOrder: 1 }, + ]); + + await expect(repo.realignAudioTrackOrder(4)).resolves.toBe(2); + + expect(db.transaction).toHaveBeenCalledTimes(1); + expect(chains.select[0]!.mocks.for).toHaveBeenCalledWith('update'); + expect(chains.update).toHaveLength(1); + expect(chains.update[0]!.mocks.set).toHaveBeenCalledWith({ sortOrder: expect.anything() }); + }); + + it('does not write when the tracks already play in path order', async () => { + const { repo, queues, chains } = makeRepo(); + queues.select.push([{ id: 4 }]); + queues.select.push([ + { id: 41, absolutePath: '/books/Book/Part 1.mp3', format: 'mp3', sortOrder: 0 }, + { id: 42, absolutePath: '/books/Book/Part 2.mp3', format: 'mp3', sortOrder: 1 }, + ]); + + await expect(repo.realignAudioTrackOrder(4)).resolves.toBe(0); + + expect(chains.update).toHaveLength(0); + }); + }); + describe('remapAudioPositions', () => { it('moves audio bookmarks and progress percentages in one transaction, skipping finished books and unplaceable rows', async () => { const { repo, queues, db, chains } = makeRepo(); diff --git a/server/src/modules/scanner/scanner.repository.ts b/server/src/modules/scanner/scanner.repository.ts index 4f9c927e2..0db1dd613 100644 --- a/server/src/modules/scanner/scanner.repository.ts +++ b/server/src/modules/scanner/scanner.repository.ts @@ -1,3 +1,4 @@ +import { realignAudioTrackOrder } from '../../db/audio-track-order'; import { deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../../db/book-file-hash-history'; import { Inject, Injectable } from '@nestjs/common'; import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm'; @@ -445,6 +446,10 @@ export class ScannerRepository { }); } + realignAudioTrackOrder(bookId: number): Promise { + return this.db.transaction((tx) => realignAudioTrackOrder(tx, bookId)); + } + async findBookFilesByBookIds(bookIds: number[]): Promise<(typeof bookFiles.$inferSelect)[]> { if (bookIds.length === 0) return []; return this.db.select().from(bookFiles).where(inArray(bookFiles.bookId, bookIds)); diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index 6bb7f55a3..5483c694d 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -127,6 +127,7 @@ function makeRepo(overrides: Record = {}) { findBookFilesByBookIds: vi.fn().mockResolvedValue([]), findBookFileFormats: vi.fn().mockResolvedValue([]), remapAudioPositions: vi.fn().mockResolvedValue({ bookmarks: 0, progress: 0 }), + realignAudioTrackOrder: vi.fn().mockResolvedValue(0), findBookById: vi.fn().mockResolvedValue(null), findBooksByIds: vi.fn().mockResolvedValue([]), findBookCardData: vi.fn().mockResolvedValue({ rows: [], authorRows: [], fileRows: [], genreRows: [] }), @@ -1811,6 +1812,140 @@ describe('audio multi-file audiobook', () => { }); }); + describe('stored track order', () => { + const tracks = [ + makeFileStat({ absolutePath: '/library/Book/Part 01.mp3', relPath: 'Book/Part 01.mp3', ino: 1001n }), + makeFileStat({ absolutePath: '/library/Book/Part 02.mp3', relPath: 'Book/Part 02.mp3', ino: 1002n }), + ]; + + async function scan(repo: ReturnType, files: FileStat[]) { + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/library/Book', files)], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + const done = awaitScan(repo); + const { service } = makeService(repo); + await service.startScan(1, 'manual'); + await done; + } + + it('realigns the stored track order once every file of the book is registered', async () => { + const repo = makeRepo(); + + await scan(repo, tracks); + + expect(repo.realignAudioTrackOrder).toHaveBeenCalledTimes(1); + expect(repo.realignAudioTrackOrder).toHaveBeenCalledWith(1); + // Numbering written by another scan in the meantime is only seen after this one is done writing. + const realignedAt = repo.realignAudioTrackOrder.mock.invocationCallOrder[0]!; + expect(Math.max(...repo.createBookFile.mock.invocationCallOrder)).toBeLessThan(realignedAt); + expect(realignedAt).toBeLessThan(repo.updateBookPrimaryFile.mock.invocationCallOrder[0]!); + }); + + it('realigns after a rescan that renumbered existing tracks, without disturbing the bookmark remap', async () => { + const repo = makeRepo({ + findBooksByLibraryFolder: vi + .fn() + .mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Book', status: 'present' }]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue( + tracks.map((track, index) => + makeBookFile({ + id: 11 + index, + bookId: 1, + absolutePath: track.absolutePath, + relPath: track.relPath, + ino: track.ino, + format: 'mp3', + sortOrder: 1 - index, + durationSeconds: 60, + }), + ), + ), + findBookFileFormats: vi.fn().mockResolvedValue([ + { id: 11, format: 'mp3' }, + { id: 12, format: 'mp3' }, + ]), + }); + + await scan(repo, tracks); + + expect(repo.updateBookFile).toHaveBeenCalledWith(11, { sortOrder: 0 }); + expect(repo.updateBookFile).toHaveBeenCalledWith(12, { sortOrder: 1 }); + expect(repo.realignAudioTrackOrder).toHaveBeenCalledWith(1); + expect(repo.remapAudioPositions).toHaveBeenCalledTimes(1); + }); + + it('leaves a book alone when the scan did not renumber any of its tracks', async () => { + const repo = makeRepo({ + findBooksByLibraryFolder: vi + .fn() + .mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Book', status: 'present' }]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue( + tracks.map((track, index) => + makeBookFile({ + id: 11 + index, + bookId: 1, + absolutePath: track.absolutePath, + relPath: track.relPath, + ino: track.ino, + format: 'mp3', + sortOrder: index, + durationSeconds: 60, + }), + ), + ), + }); + + await scan(repo, tracks); + + expect(repo.updateBookFile).not.toHaveBeenCalled(); + expect(repo.realignAudioTrackOrder).not.toHaveBeenCalled(); + }); + + it('leaves the order of a book without audio alone', async () => { + const repo = makeRepo(); + + await scan(repo, [makeFileStat(), makeFileStat({ absolutePath: '/library/Book/book.pdf', relPath: 'Book/book.pdf', ino: 1003n })]); + + expect(repo.realignAudioTrackOrder).not.toHaveBeenCalled(); + }); + + it('logs how many tracks it renumbered', async () => { + const repo = makeRepo({ realignAudioTrackOrder: vi.fn().mockResolvedValue(2) }); + + await scan(repo, tracks); + + expect(Logger.prototype.log).toHaveBeenCalledWith( + expect.stringMatching(/^\[scanner\.realign_track_order\] \[end\] bookId=1 durationMs=\d+ renumbered=2 - /), + ); + }); + + it('still finishes the scan when the realignment fails', async () => { + const repo = makeRepo({ realignAudioTrackOrder: vi.fn().mockRejectedValue(new Error('deadlock detected')) }); + + await scan(repo, tracks); + + expect(repo.updateBookPrimaryFile).toHaveBeenCalled(); + expect(repo.completeScanJob).toHaveBeenCalled(); + expect(repo.failScanJob).not.toHaveBeenCalled(); + expect(Logger.prototype.warn).toHaveBeenCalledWith( + expect.stringMatching(/^\[scanner\.realign_track_order\] \[fail\] bookId=1 durationMs=\d+ errorClass=Error error="deadlock detected" - /), + ); + }); + + it('realigns after a targeted scan of a book folder, the scan the watcher runs for each new file', async () => { + mockBuildSingleCandidate.mockResolvedValue(makeCandidate('/library/Book', tracks)); + const repo = makeRepo(); + const { service } = makeService(repo); + + await (service as any).scanBookFolder('/library/Book/Part 02.mp3', 1); + + expect(repo.realignAudioTrackOrder).toHaveBeenCalledWith(1); + }); + }); + describe('disc folders reordered by a rescan', () => { // Stored under the old file-name order, which interleaved the discs. const tracks = [ diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index db73ebb09..1befeb11e 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -1803,6 +1803,24 @@ export class ScannerService implements OnApplicationBootstrap { return true; } + /** Never throws: the files are registered either way, and the next scan of the book realigns them. */ + private async realignAudioTrackOrder(bookId: number): Promise { + const event = 'scanner.realign_track_order'; + const startedAt = Date.now(); + try { + const renumbered = await this.scannerRepo.realignAudioTrackOrder(bookId); + if (renumbered > 0) { + this.logger.log( + `[${event}] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} renumbered=${renumbered} - track order realigned with file paths`, + ); + } + } catch (err) { + this.logger.warn( + `[${event}] [fail] bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - track order realignment failed`, + ); + } + } + private async processCandidate( candidate: BookCandidate, libraryId: number, @@ -1841,6 +1859,7 @@ export class ScannerService implements OnApplicationBootstrap { const registeredFiles: RegisteredFile[] = []; const formerOwnerBookIds = new Set(); let mediaOverlayChanged = false; + let numberedTracks = false; for (let sortOrder = 0; sortOrder < candidate.files.length; sortOrder++) { const fileStat = candidate.files[sortOrder]; @@ -1886,6 +1905,9 @@ export class ScannerService implements OnApplicationBootstrap { if (processResult.mediaOverlayChanged) mediaOverlayChanged = true; if (processResult.fileId !== null) { + if (format !== null && isAudioFormat(format) && (processResult.isNew || processResult.reassigned || previousSortOrder !== sortOrder)) { + numberedTracks = true; + } registeredFiles.push({ fileId: processResult.fileId, format, @@ -1903,6 +1925,10 @@ export class ScannerService implements OnApplicationBootstrap { } } + // Each track above was numbered from this scan's own listing of the folder. A scan that listed + // the folder before newer files arrived can finish after one that saw them, mixing the two. + if (numberedTracks) await this.realignAudioTrackOrder(book.id); + // Phase 2: Pick winner (primary file) from all registered content files. const contentFiles = registeredFiles.filter((f) => f.role === 'content'); diff --git a/server/src/modules/upload/upload-processor.service.test.ts b/server/src/modules/upload/upload-processor.service.test.ts index dbf47c69d..ecf01d809 100644 --- a/server/src/modules/upload/upload-processor.service.test.ts +++ b/server/src/modules/upload/upload-processor.service.test.ts @@ -1,16 +1,19 @@ vi.mock('fs/promises', () => ({ stat: vi.fn() })); vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() })); +vi.mock('../../db/audio-track-order', () => ({ realignAudioTrackOrder: vi.fn() })); import { InternalServerErrorException } from '@nestjs/common'; import { PgDialect } from 'drizzle-orm/pg-core'; import type { SQL } from 'drizzle-orm'; import { stat } from 'fs/promises'; import { computeFileHash } from '../scanner/lib/hash'; +import { realignAudioTrackOrder } from '../../db/audio-track-order'; import { books, bookFiles, bookMetadata, uploadSessions } from '../../db/schema'; import { UploadProcessorService } from './upload-processor.service'; const mockStat = stat as MockedFunction; const mockComputeFileHash = computeFileHash as MockedFunction; +const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction; describe('UploadProcessorService', () => { const metadataService = { @@ -172,6 +175,24 @@ describe('UploadProcessorService', () => { expect(orchestrator.scheduleImportedBooksIfEligible).not.toHaveBeenCalled(); }); + it('renumbers the tracks of the book an audio file joins, inside the same transaction', async () => { + selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]); + + await service.createBookRecord(1, 2, '/folder', '/folder/Part 03.mp3', 'book/Part 03.mp3', 'mp3', 5000); + + expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1); + expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 99); + expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(insertBookFilesValues.mock.invocationCallOrder[0]!); + }); + + it('leaves track order alone for an audio file that starts a book, or a file that is not audio', async () => { + await service.createBookRecord(1, 2, '/folder', '/folder/Part 01.mp3', 'book/Part 01.mp3', 'mp3', 5000); + selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]); + await service.createBookRecord(1, 2, '/folder', '/folder/book.epub', 'book/book.epub', 'epub', 5000); + + expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled(); + }); + it('preserves oversized MergerFS inode values before persisting', async () => { mockStat.mockResolvedValueOnce({ ino: 14351917807348929000n, mtime: new Date('2024-01-01') } as Awaited>); @@ -458,6 +479,44 @@ describe('UploadProcessorService', () => { expect(db.transaction).toHaveBeenCalledTimes(1); }); + it('renumbers the tracks of each book that received audio once every file of the unit is in', async () => { + insertBooksReturning.mockResolvedValueOnce([{ id: 42 }]); + selectLimit + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 42 }]) + .mockResolvedValueOnce([]); + + await service.createUnitBookRecords(1, 2, unitFiles); + + expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1); + expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 42); + expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(Math.max(...insertBookFilesValues.mock.invocationCallOrder)); + }); + + it('renumbers a book the unit joined, whose tracks the unit numbered from zero', async () => { + selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['mp3'] }]).mockResolvedValueOnce([]); + + await service.createUnitBookRecords(1, 2, [{ ...unitFiles[0]!, sortOrder: 0 }]); + + expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 99); + }); + + it('does not renumber a book that only received files that are not audio', async () => { + selectLimit.mockResolvedValueOnce([{ id: 99, primaryFileId: 5, status: 'present', formatPriority: ['epub'] }]).mockResolvedValueOnce([]); + + await service.createUnitBookRecords(1, 2, [ + { folderPath: '/library/Book', absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', format: 'epub', sizeBytes: 10 }, + ]); + + expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled(); + }); + + it('rolls the whole unit back when the renumbering fails', async () => { + mockRealignAudioTrackOrder.mockRejectedValueOnce(new Error('deadlock detected')); + + await expect(service.createUnitBookRecords(1, 2, unitFiles)).rejects.toThrow('deadlock detected'); + }); + it('refuses an empty unit rather than creating a book with no files', async () => { await expect(service.createUnitBookRecords(1, 2, [])).rejects.toBeInstanceOf(InternalServerErrorException); }); diff --git a/server/src/modules/upload/upload-processor.service.ts b/server/src/modules/upload/upload-processor.service.ts index 537e00942..8e0a4c98e 100644 --- a/server/src/modules/upload/upload-processor.service.ts +++ b/server/src/modules/upload/upload-processor.service.ts @@ -1,3 +1,4 @@ +import { realignAudioTrackOrder } from '../../db/audio-track-order'; import { deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../../db/book-file-hash-history'; import { Inject, Injectable, InternalServerErrorException, Logger, Optional } from '@nestjs/common'; import { stat } from 'fs/promises'; @@ -148,6 +149,9 @@ export class UploadProcessorService { if (upserted.joinedBook && (fileOptions.role ?? 'content') === 'content') { await this.reselectPrimaryFile(tx, upserted.joinedBook); } + if (upserted.joinedBook && isAudioFormat(format)) { + await realignAudioTrackOrder(tx, upserted.bookId); + } if (uploadSessionId) { await tx.update(uploadSessions).set({ resultBookId: upserted.bookId, updatedAt: new Date() }).where(eq(uploadSessions.id, uploadSessionId)); } @@ -177,10 +181,12 @@ export class UploadProcessorService { const createdBookIds: number[] = []; const attachedFileIds: number[] = []; const joinedBooks = new Map(); + const audioBookIds = new Set(); for (const [index, file] of files.entries()) { const result = await this.upsertBookFile(tx, libraryId, libraryFolderId, file, measured[index]!); if (!bookIds.includes(result.bookId)) bookIds.push(result.bookId); + if (isAudioFormat(file.format)) audioBookIds.add(result.bookId); if (result.createdBook) createdBookIds.push(result.bookId); // A row on a book this unit created goes away with the book, so only rows attached to a // book that was already there need remembering - and only the ones actually inserted, @@ -194,6 +200,9 @@ export class UploadProcessorService { } } + // The unit numbered its files from zero, which collides with the numbers already on a book it joined. + for (const bookId of audioBookIds) await realignAudioTrackOrder(tx, bookId); + const replacedPrimaries: ReplacedPrimary[] = []; for (const joined of joinedBooks.values()) { const replaced = await this.reselectPrimaryFile(tx, joined); diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index af7afde86..9e9434c82 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -4,6 +4,7 @@ vi.mock('fs/promises', async () => { }); vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() })); +vi.mock('../../db/audio-track-order', () => ({ realignAudioTrackOrder: vi.fn() })); vi.mock('../metadata/lib/epub', () => ({ extractEpubMetadata: vi.fn() })); vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), extractCbrMetadata: vi.fn(), extractCb7Metadata: vi.fn() })); @@ -18,11 +19,13 @@ import { extractCbzMetadata } from '../metadata/lib/cbz-metadata'; import { parseMobiFile } from '../metadata/lib/mobi-parser'; import { parsePdfFile } from '../metadata/lib/pdf-parser'; import { computeFileHash } from '../scanner/lib/hash'; +import { realignAudioTrackOrder } from '../../db/audio-track-order'; import { books } from '../../db/schema'; import { UploadService } from './upload.service'; const mockFsAccess = fsAccess as MockedFunction; +const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction; const mockLstat = lstat as MockedFunction; const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; @@ -1212,6 +1215,26 @@ describe('UploadService', () => { expect(processor.reconcileCoversAsync).toHaveBeenCalledWith([10]); }); + it('places an added track by its name before the transaction commits, instead of after every other track', async () => { + validator.sanitizeFilename.mockReturnValue('chapter-02.mp3'); + validator.validateFormat.mockReturnValue('mp3'); + db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict()); + + await service.addFileToBook(10, 'chapter-02.mp3', {} as any, user); + + expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1); + expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 10); + expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(insertValues.mock.invocationCallOrder[0]!); + }); + + it('leaves track order alone when the added file is not audio', async () => { + db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict()); + + await service.addFileToBook(10, 'book.epub', {} as any, user); + + expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled(); + }); + it('schedules audio chapter extraction when an M4B is added to an EPUB-primary book', async () => { mockUploadedFormat('m4b'); db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict()); diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index d85d84a96..ed6a93a47 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -11,6 +11,7 @@ import { buildPatternTokens, patternReferencesToken } from '../../common/utils/p import { selectPrimaryFileKeepingCurrent } from '../../common/utils/primary-file-selection.utils'; import { DB } from '../../db'; +import { realignAudioTrackOrder } from '../../db/audio-track-order'; import * as schema from '../../db/schema'; import { books, bookFiles, libraries, libraryFolders } from '../../db/schema'; import type { RequestUser } from '../../common/types/request-user'; @@ -303,6 +304,8 @@ export class UploadService { }); if (!inserted) throw new Error('Failed to insert book file record'); + // A track added by hand has no number yet, which would play it after every other track. + if (isAudioFormat(format)) await realignAudioTrackOrder(tx, bookId); const contentFiles = await tx .select({ diff --git a/server/test/audiobook-track-order.e2e-spec.ts b/server/test/audiobook-track-order.e2e-spec.ts new file mode 100644 index 000000000..958cde1fc --- /dev/null +++ b/server/test/audiobook-track-order.e2e-spec.ts @@ -0,0 +1,177 @@ +import { randomUUID } from 'crypto'; +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { eq } from 'drizzle-orm'; + +import { bookFiles, books } from '../src/db/schema'; +import { MetadataService } from '../src/modules/metadata/metadata.service'; +import { ScannerRepository } from '../src/modules/scanner/scanner.repository'; +import { ScannerService } from '../src/modules/scanner/scanner.service'; +import { closeE2EContext, createE2EContext, seedLibrary, triggerAndWaitForLibraryScan, type E2EContext } from './e2e/app-harness'; + +const TEST_TIMEOUT_MS = 60_000; + +const trackName = (number: string) => `Project Hail Mary (${number}).mp3`; + +/** Distinct bytes per file, so the scanner never mistakes one track for a moved copy of another. */ +const trackBytes = (number: string) => Buffer.from(`ID3 track ${number} ${randomUUID()}`); + +/** Older than the scanner's stability window, so no scan stops to wait for these files to settle. */ +async function writeSettledFile(path: string, content: Buffer): Promise { + await writeFile(path, content); + const settled = new Date(Date.now() - 5 * 60_000); + await utimes(path, settled, settled); +} + +function multipart(fileName: string, content: Buffer): { body: Buffer; boundary: string } { + const boundary = `----bookorbit-track-order-${randomUUID()}`; + const preamble = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: audio/mpeg\r\n\r\n`, + 'utf8', + ); + return { boundary, body: Buffer.concat([preamble, content, Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')]) }; +} + +describe('Audiobook track order (e2e)', () => { + let ctx: E2EContext; + const roots: string[] = []; + + beforeAll(async () => { + ctx = await createE2EContext({ isolateAppData: true, multipart: true }); + // The shared no-op metadata stub predates these audio steps, which a scan and an added track run. + Object.assign(ctx.app.get(MetadataService), { + extractAndAggregateAudioDuration: vi.fn().mockResolvedValue(undefined), + extractMergedAudioChapters: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterAll(async () => { + await closeE2EContext(ctx); + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function createBookFolder(): Promise<{ root: string; folder: string }> { + const root = await mkdtemp(join(tmpdir(), 'bookorbit-track-order-')); + roots.push(root); + const folder = join(root, 'Andy Weir', 'Project Hail Mary (2021)'); + await mkdir(folder, { recursive: true }); + return { root, folder }; + } + + async function bookIdFor(folder: string): Promise { + const [book] = await ctx.db.select({ id: books.id }).from(books).where(eq(books.folderPath, folder)); + if (!book) throw new Error(`No book for ${folder}`); + return book.id; + } + + async function fileNames(bookId: number): Promise> { + const rows = await ctx.db.select({ id: bookFiles.id, absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, bookId)); + return new Map(rows.map((row) => [row.id, row.absolutePath.split('/').pop()!])); + } + + /** The order the web and iOS players queue the tracks in. */ + async function playbackOrder(bookId: number): Promise { + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/audiobooks/${bookId}/manifest`, + headers: { authorization: `Bearer ${ctx.adminToken}` }, + }); + expect(response.statusCode).toBe(200); + const names = await fileNames(bookId); + return (response.json() as { assets: Array<{ fileId: number }> }).assets.map((asset) => names.get(asset.fileId)!); + } + + /** The order the book's Files tab lists the tracks in. */ + async function filesTabOrder(bookId: number): Promise { + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${bookId}`, + headers: { authorization: `Bearer ${ctx.adminToken}` }, + }); + expect(response.statusCode).toBe(200); + return (response.json() as { files: Array<{ format: string | null; absolutePath: string }> }).files + .filter((file) => file.format === 'mp3') + .map((file) => file.absolutePath.split('/').pop()!); + } + + it( + 'plays tracks in name order when a folder scan that listed the folder early finishes after a newer one', + async () => { + const { root, folder } = await createBookFolder(); + for (const number of ['03', '04', '05']) await writeSettledFile(join(folder, trackName(number)), trackBytes(number)); + const { libraryId } = await seedLibrary(ctx.db, { rootPath: root, mode: 'book_per_folder' }); + + const scanner = ctx.app.get(ScannerService); + const repo = ctx.app.get(ScannerRepository); + const createBookFile = repo.createBookFile.bind(repo); + let staleScanPaused!: () => void; + const staleScanReachedItsFirstWrite = new Promise((resolve) => (staleScanPaused = resolve)); + let resumeStaleScan!: () => void; + const staleScanMayContinue = new Promise((resolve) => (resumeStaleScan = resolve)); + const spy = vi.spyOn(repo, 'createBookFile').mockImplementationOnce(async (data) => { + staleScanPaused(); + await staleScanMayContinue; + return createBookFile(data); + }); + + try { + // The watcher's scan for track 03 lists the folder while only tracks 03 to 05 are there. + const staleScan = (scanner as unknown as { scanBookFolder(path: string, libraryId: number): Promise }).scanBookFolder( + join(folder, trackName('03')), + libraryId, + ); + await staleScanReachedItsFirstWrite; + + // The rest of the book arrives, and the scan for track 01 numbers the whole folder first. + await writeSettledFile(join(folder, 'cover.jpg'), Buffer.from(`cover ${randomUUID()}`)); + for (const number of ['01', '02']) await writeSettledFile(join(folder, trackName(number)), trackBytes(number)); + await (scanner as unknown as { scanBookFolder(path: string, libraryId: number): Promise }).scanBookFolder( + join(folder, trackName('01')), + libraryId, + ); + + resumeStaleScan(); + await staleScan; + } finally { + resumeStaleScan(); + spy.mockRestore(); + } + + const bookId = await bookIdFor(folder); + const inNameOrder = ['01', '02', '03', '04', '05'].map(trackName); + expect(await filesTabOrder(bookId)).toEqual(inNameOrder); + expect(await playbackOrder(bookId)).toEqual(inNameOrder); + }, + TEST_TIMEOUT_MS, + ); + + it( + 'plays a track added from the Files tab where its name puts it, not after every other track', + async () => { + const { root, folder } = await createBookFolder(); + for (const number of ['01', '02', '04']) await writeSettledFile(join(folder, trackName(number)), trackBytes(number)); + const { libraryId } = await seedLibrary(ctx.db, { rootPath: root, mode: 'book_per_folder' }); + await triggerAndWaitForLibraryScan(ctx, libraryId); + const bookId = await bookIdFor(folder); + + const { body, boundary } = multipart(trackName('03'), trackBytes('03')); + const response = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/books/${bookId}/files`, + headers: { + authorization: `Bearer ${ctx.adminToken}`, + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(body.length), + }, + payload: body, + }); + expect(response.statusCode).toBe(201); + + const inNameOrder = ['01', '02', '03', '04'].map(trackName); + expect(await filesTabOrder(bookId)).toEqual(inNameOrder); + expect(await playbackOrder(bookId)).toEqual(inNameOrder); + }, + TEST_TIMEOUT_MS, + ); +}); diff --git a/server/test/e2e/app-harness.ts b/server/test/e2e/app-harness.ts index 5f0f7b8eb..ed73bbef3 100644 --- a/server/test/e2e/app-harness.ts +++ b/server/test/e2e/app-harness.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { expect, vi } from 'vitest'; import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; import { count, eq, inArray, isNull, sql } from 'drizzle-orm'; import { hash } from 'bcryptjs'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -113,9 +114,10 @@ export interface E2EContext { /** * Scans write and prune covers under APP_DATA_PATH, which `.env` points at the developer's own - * data folder. Suites that scan and do not set their own path pass `isolateAppData`. + * data folder. Suites that scan and do not set their own path pass `isolateAppData`. Suites that + * upload files pass `multipart`, which `main.ts` registers for the real server. */ -export async function createE2EContext(options: { isolateAppData?: boolean } = {}): Promise { +export async function createE2EContext(options: { isolateAppData?: boolean; multipart?: boolean } = {}): Promise { let isolatedAppData: E2EContext['isolatedAppData'] = null; if (options.isolateAppData) { isolatedAppData = { path: await mkdtemp(join(tmpdir(), 'bookorbit-e2e-data-')), previous: process.env.APP_DATA_PATH }; @@ -139,6 +141,7 @@ export async function createE2EContext(options: { isolateAppData?: boolean } = { ); app.useGlobalFilters(new GlobalExceptionFilter()); await app.register(fastifyCookie as never); + if (options.multipart) await app.register(fastifyMultipart as never); await app.init(); await app.getHttpAdapter().getInstance().ready();