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:
@@ -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