mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<NodePgDatabase<typeof schema>, 'select' | 'update'>, bookId: number): Promise<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number> {
|
||||
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));
|
||||
|
||||
@@ -127,6 +127,7 @@ function makeRepo(overrides: Record<string, unknown> = {}) {
|
||||
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<typeof makeRepo>, 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 = [
|
||||
|
||||
@@ -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<void> {
|
||||
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<number>();
|
||||
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');
|
||||
|
||||
|
||||
@@ -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<typeof stat>;
|
||||
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
|
||||
const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction<typeof realignAudioTrackOrder>;
|
||||
|
||||
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<ReturnType<typeof stat>>);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<number, JoinedBook>();
|
||||
const audioBookIds = new Set<number>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<typeof fsAccess>;
|
||||
const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction<typeof realignAudioTrackOrder>;
|
||||
const mockLstat = lstat as MockedFunction<typeof lstat>;
|
||||
const mockReaddir = readdir as MockedFunction<typeof readdir>;
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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<Map<number, string>> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<void>((resolve) => (staleScanPaused = resolve));
|
||||
let resumeStaleScan!: () => void;
|
||||
const staleScanMayContinue = new Promise<void>((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<void> }).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<void> }).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,
|
||||
);
|
||||
});
|
||||
@@ -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<E2EContext> {
|
||||
export async function createE2EContext(options: { isolateAppData?: boolean; multipart?: boolean } = {}): Promise<E2EContext> {
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user