Files
ReadMeABook/tests/utils/chapter-merger.test.ts
kikootwo a0f2ba680d Add rootless Podman fixes, and others
improve container startup for rootless Podman, plus related refactors and tests. Key changes:

- Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation.
- Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification.
- Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests.
- Update many admin/auth API routes and tests to reflect changes in settings and integrations.
- Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow.

These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
2026-02-04 14:05:28 -05:00

740 lines
24 KiB
TypeScript

/**
* Component: Chapter Merger Utility Tests
* Documentation: documentation/features/chapter-merging.md
*/
import { EventEmitter } from 'events';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
analyzeChapterFiles,
checkDiskSpace,
detectChapterFiles,
estimateOutputSize,
formatDuration,
mergeChapters,
probeAudioFile,
} from '@/lib/utils/chapter-merger';
const execState = vi.hoisted(() => {
const state = {
handler: null as null | ((command: string) => { stdout?: string; error?: Error }),
};
const custom = Symbol.for('nodejs.util.promisify.custom');
const exec = vi.fn();
(exec as any)[custom] = (command: string) =>
new Promise((resolve, reject) => {
const result = state.handler ? state.handler(command) : { stdout: '' };
if (result.error) {
reject(result.error);
return;
}
resolve({ stdout: result.stdout ?? '', stderr: '' });
});
return { exec, state };
});
const spawnMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
access: vi.fn(),
stat: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
unlink: vi.fn(),
constants: { R_OK: 4 },
}));
vi.mock('child_process', () => ({
exec: execState.exec,
spawn: spawnMock,
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
function createSpawnProcess(exitCode = 0, stderrData = '') {
const proc = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
kill: () => void;
};
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
setImmediate(() => {
if (stderrData) {
proc.stderr.emit('data', Buffer.from(stderrData));
}
proc.emit('close', exitCode);
});
return proc;
}
function mockExecImplementation(handlers: (command: string) => { stdout?: string; error?: Error }) {
execState.state.handler = handlers;
}
describe('chapter merger', () => {
beforeEach(() => {
vi.clearAllMocks();
execState.state.handler = null;
});
it('detects when chapter merging should be skipped', async () => {
await expect(detectChapterFiles(['one.mp3', 'two.mp3'])).resolves.toBe(false);
await expect(detectChapterFiles(['one.mp3', 'two.m4b', 'three.mp3'])).resolves.toBe(false);
await expect(detectChapterFiles(['one.wav', 'two.wav', 'three.wav'])).resolves.toBe(false);
});
it('detects eligible chapter files', async () => {
await expect(detectChapterFiles(['one.mp3', 'two.mp3', 'three.mp3'])).resolves.toBe(true);
});
it('orders chapters by metadata when track numbers are sequential', async () => {
const files = ['/tmp/b.mp3', '/tmp/a.mp3', '/tmp/c.mp3'];
const probeMap: Record<string, { duration: number; bitrate: number; track: number }> = {
'/tmp/b.mp3': { duration: 60, bitrate: 128000, track: 1 },
'/tmp/a.mp3': { duration: 60, bitrate: 128000, track: 2 },
'/tmp/c.mp3': { duration: 60, bitrate: 128000, track: 3 },
};
mockExecImplementation((command) => {
const matches = command.match(/"([^"]+)"/g) ?? [];
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
const probe = probeMap[filePath];
if (!probe) {
throw new Error(`Missing probe data for ${filePath}`);
}
const payload = {
format: {
duration: String(probe.duration),
bit_rate: String(probe.bitrate),
tags: { track: String(probe.track) },
},
};
return { stdout: JSON.stringify(payload) };
});
const ordered = await analyzeChapterFiles(files);
expect(ordered.map((file) => path.basename(file.path))).toEqual(['b.mp3', 'a.mp3', 'c.mp3']);
expect(ordered[0].chapterTitle).toBe('Chapter 1');
});
it('orders chapters by filename when track numbers are missing', async () => {
const files = ['/tmp/02 - Middle.mp3', '/tmp/01 - Start.mp3', '/tmp/03 - End.mp3'];
const probeMap: Record<string, { duration: number; bitrate: number; title?: string }> = {
'/tmp/02 - Middle.mp3': { duration: 60, bitrate: 128000 },
'/tmp/01 - Start.mp3': { duration: 60, bitrate: 128000 },
'/tmp/03 - End.mp3': { duration: 60, bitrate: 128000 },
};
mockExecImplementation((command) => {
const matches = command.match(/"([^"]+)"/g) ?? [];
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
const probe = probeMap[filePath];
if (!probe) {
throw new Error(`Missing probe data for ${filePath}`);
}
const payload = {
format: {
duration: String(probe.duration),
bit_rate: String(probe.bitrate),
tags: {},
},
};
return { stdout: JSON.stringify(payload) };
});
const ordered = await analyzeChapterFiles(files);
expect(ordered.map((file) => path.basename(file.path))).toEqual([
'01 - Start.mp3',
'02 - Middle.mp3',
'03 - End.mp3',
]);
expect(ordered[0].chapterTitle).toBe('Start');
expect(ordered[1].chapterTitle).toBe('Middle');
});
it('falls back to chapter numbers when metadata title is the book title', async () => {
const files = ['/tmp/01.mp3', '/tmp/02.mp3', '/tmp/03.mp3'];
const probeMap: Record<string, { duration: number; bitrate: number; track: number; title: string }> = {
'/tmp/01.mp3': { duration: 60, bitrate: 128000, track: 1, title: 'Book Title' },
'/tmp/02.mp3': { duration: 60, bitrate: 128000, track: 2, title: 'Book Title' },
'/tmp/03.mp3': { duration: 60, bitrate: 128000, track: 3, title: 'Book Title' },
};
mockExecImplementation((command) => {
const matches = command.match(/"([^"]+)"/g) ?? [];
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
const probe = probeMap[filePath];
if (!probe) {
throw new Error(`Missing probe data for ${filePath}`);
}
const payload = {
format: {
duration: String(probe.duration),
bit_rate: String(probe.bitrate),
tags: { track: String(probe.track), title: probe.title },
},
};
return { stdout: JSON.stringify(payload) };
});
const ordered = await analyzeChapterFiles(files);
expect(ordered[0].chapterTitle).toBe('Chapter 1');
expect(ordered[1].chapterTitle).toBe('Chapter 2');
});
it('uses filename order when track numbers are not sequential', async () => {
const files = ['/tmp/02 - Two.mp3', '/tmp/01 - One.mp3', '/tmp/03 - Three.mp3'];
const probeMap: Record<string, { duration: number; bitrate: number; track: number }> = {
'/tmp/02 - Two.mp3': { duration: 60, bitrate: 128000, track: 2 },
'/tmp/01 - One.mp3': { duration: 60, bitrate: 128000, track: 1 },
'/tmp/03 - Three.mp3': { duration: 60, bitrate: 128000, track: 4 },
};
mockExecImplementation((command) => {
const matches = command.match(/"([^"]+)"/g) ?? [];
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
const probe = probeMap[filePath];
if (!probe) {
throw new Error(`Missing probe data for ${filePath}`);
}
const payload = {
format: {
duration: String(probe.duration),
bit_rate: String(probe.bitrate),
tags: { track: String(probe.track) },
},
};
return { stdout: JSON.stringify(payload) };
});
const ordered = await analyzeChapterFiles(files);
expect(ordered.map((file) => path.basename(file.path))).toEqual([
'01 - One.mp3',
'02 - Two.mp3',
'03 - Three.mp3',
]);
});
it('formats durations for logs', () => {
expect(formatDuration(65000)).toBe('1m 5s');
expect(formatDuration(3601000)).toBe('1h 0m 1s');
});
it('estimates output size with overhead', async () => {
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === '/tmp/one.mp3') return { size: 100 };
if (filePath === '/tmp/two.mp3') return { size: 200 };
throw new Error('missing');
});
const size = await estimateOutputSize(['/tmp/one.mp3', '/tmp/two.mp3', '/tmp/missing.mp3']);
expect(size).toBe(330);
});
it('checks disk space when df output is available', async () => {
mockExecImplementation(() => ({ stdout: '1024\n' }));
const space = await checkDiskSpace('/tmp');
expect(space).toBe(1024 * 1024);
});
it('returns null when disk space cannot be determined', async () => {
mockExecImplementation(() => ({ error: new Error('df missing') }));
const space = await checkDiskSpace('/tmp');
expect(space).toBeNull();
});
it('returns an error when no chapters are provided', async () => {
const result = await mergeChapters([], {
title: 'Book',
author: 'Author',
outputPath: '/tmp/output.m4b',
});
expect(result.success).toBe(false);
expect(result.error).toContain('No chapters');
});
it('merges chapters and returns success details', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' },
{ path: '/tmp/two.mp3', filename: 'two.mp3', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
mockExecImplementation((command) => {
if (command.startsWith('ffmpeg -encoders')) {
return { stdout: 'aac encoder' };
}
if (command.startsWith('ffprobe')) {
const payload = {
format: {
duration: '120',
bit_rate: '128000',
tags: {},
},
};
return { stdout: JSON.stringify(payload) };
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { error: new Error(`Unexpected command: ${command}`) };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(true);
expect(result.chapterCount).toBe(2);
expect(result.totalDuration).toBe(120000);
expect(spawnMock).toHaveBeenCalled();
});
it('parses probe metadata including track numbers', async () => {
mockExecImplementation(() => ({
stdout: JSON.stringify({
format: {
duration: '90',
bit_rate: '256000',
tags: { track: '1/10', title: 'Chapter One' },
},
}),
}));
const probe = await probeAudioFile('/tmp/chapter.mp3');
expect(probe.duration).toBe(90000);
expect(probe.bitrate).toBe(256);
expect(probe.trackNumber).toBe(1);
expect(probe.title).toBe('Chapter One');
});
it('returns failure when ffmpeg merge fails', async () => {
const chapters = [
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffmpeg -encoders')) {
return { stdout: 'aac encoder' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(1, 'Error: merge failed'));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath: '/tmp/output.m4b',
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/FFmpeg merge failed/i);
});
it('returns failure when output validation fails', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: {
duration: '30',
bit_rate: '128000',
tags: {},
},
}),
};
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Merge validation failed/i);
});
it('returns failure when file integrity validation fails', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: {
duration: '120',
bit_rate: '128000',
tags: {},
},
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { error: new Error('decode failed') };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/File integrity test failed/i);
});
it('returns failure when merged file size is too small', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 200 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: {
duration: '120',
bit_rate: '128000',
tags: {},
},
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/File size too small/i);
});
it('returns failure when validation encounters an error', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return { error: new Error('probe failed') };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Validation error/i);
});
it('logs encoding estimates for long MP3 audiobooks', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'One' },
{ path: '/tmp/two.mp3', filename: 'two.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'Two' },
];
const logger = {
info: vi.fn().mockResolvedValue(undefined),
warn: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
};
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 120 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffmpeg -encoders')) {
return { stdout: 'libfdk_aac' };
}
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: {
duration: '7200',
bit_rate: '128000',
tags: {},
},
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
}, logger);
expect(result.success).toBe(true);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('long audiobook'));
});
it('returns failure when output file is not created', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
throw new Error('missing');
}
return undefined;
});
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation(() => ({ stdout: '' }));
spawnMock.mockReturnValue(createSpawnProcess(0));
const result = await mergeChapters(chapters, {
title: 'Book',
author: 'Author',
outputPath,
});
expect(result.success).toBe(false);
expect(result.error).toMatch(/Merged file not created/i);
});
describe('metadata escaping', () => {
it('does NOT escape single quotes in metadata (they are literal in double-quoted shell strings)', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: { duration: '60', bit_rate: '128000', tags: {} },
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
await mergeChapters(chapters, {
title: "It's Not Her",
author: "O'Brien",
narrator: "Jane's Voice",
outputPath,
});
// Get the args passed to spawn
const spawnCall = spawnMock.mock.calls[0];
const args = spawnCall[1] as string[];
// Find the title metadata arg (format after parsing: title="It's Not Her)
const titleArg = args.find((arg: string) => arg.startsWith('title='));
const albumArtistArg = args.find((arg: string) => arg.startsWith('album_artist='));
const composerArg = args.find((arg: string) => arg.startsWith('composer='));
// Single quotes should appear as-is ('s), NOT escaped with backslash (\'s)
// The args contain the value with opening quote: title="It's Not Her
expect(titleArg).toContain("It's Not Her");
expect(titleArg).not.toContain("\\'"); // No escaped single quotes
expect(albumArtistArg).toContain("O'Brien");
expect(albumArtistArg).not.toContain("\\'");
expect(composerArg).toContain("Jane's Voice");
expect(composerArg).not.toContain("\\'");
// Verify no backslash-escaped single quotes anywhere in args
const allArgsJoined = args.join(' ');
expect(allArgsJoined).not.toContain("\\'");
});
it('properly escapes double quotes and special shell characters', async () => {
const outputPath = '/tmp/output.m4b';
const chapters = [
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
];
fsMock.access.mockResolvedValue(undefined);
fsMock.stat.mockImplementation(async (filePath: string) => {
if (filePath === outputPath) {
return { size: 2 * 1024 * 1024 };
}
return { size: 500 * 1024 };
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
mockExecImplementation((command) => {
if (command.startsWith('ffprobe')) {
return {
stdout: JSON.stringify({
format: { duration: '60', bit_rate: '128000', tags: {} },
}),
};
}
if (command.startsWith('ffmpeg -v error')) {
return { stdout: '' };
}
return { stdout: '' };
});
spawnMock.mockReturnValue(createSpawnProcess(0));
await mergeChapters(chapters, {
title: 'Book "Quoted" $100',
author: 'Author',
outputPath,
});
// Get the args passed to spawn
const spawnCall = spawnMock.mock.calls[0];
const args = spawnCall[1] as string[];
// Find the title arg - double quotes and $ should be escaped
const titleArg = args.find((arg: string) => arg.startsWith('title='));
// Verify escaping is present for double quotes and dollar signs
expect(titleArg).toContain('\\"Quoted\\"');
expect(titleArg).toContain('\\$100');
});
});
});