feat: Implement FFmpeg metadata escaping and robust chapter index validation for audiobook generation, alongside unit test reorganization.

This commit is contained in:
Richard R 2026-01-19 17:33:29 -07:00
parent 9c941b57ed
commit 8dbf904a21
4 changed files with 58 additions and 9 deletions

View file

@ -5,7 +5,7 @@ import { existsSync, createReadStream } from 'fs';
import { basename, join, resolve } from 'path';
import { randomUUID } from 'crypto';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio } from '@/lib/server/audiobook';
import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
@ -178,6 +178,9 @@ export async function POST(request: NextRequest) {
// Create intermediate directory
await mkdir(intermediateDir, { recursive: true });
const existingChapters = await listStoredChapters(intermediateDir, request.signal);
const hasChapters = existingChapters.length > 0;
const metaPath = join(intermediateDir, 'audiobook.meta.json');
const incomingSettings = data.settings;
let existingSettings: AudiobookGenerationSettings | null = null;
@ -187,7 +190,9 @@ export async function POST(request: NextRequest) {
existingSettings = null;
}
if (existingSettings) {
// Only enforce mismatch check if we already have generated chapters.
// If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial).
if (existingSettings && hasChapters) {
if (incomingSettings) {
const mismatch =
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
@ -203,11 +208,9 @@ export async function POST(request: NextRequest) {
);
}
}
} else if (incomingSettings) {
await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2));
}
const existingChapters = await listStoredChapters(intermediateDir, request.signal);
// Note: We deliberately do NOT write the meta file here yet.
// We wait until a chapter is successfully generated/saved below.
const existingFormats = new Set(existingChapters.map((c) => c.format));
if (existingFormats.size > 1) {
return NextResponse.json(
@ -224,10 +227,15 @@ export async function POST(request: NextRequest) {
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1;
// Use provided chapter index or find the next available index robustly (handles gaps)
// Use provided chapter index or find the next available index robustly (handles gaps)
let chapterIndex: number;
if (data.chapterIndex !== undefined) {
chapterIndex = data.chapterIndex;
const normalized = Number(data.chapterIndex);
if (!Number.isInteger(normalized) || normalized < 0) {
return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 });
}
chapterIndex = normalized;
} else {
const indices = existingChapters.map((c) => c.index);
// Find smallest non-negative integer not present
@ -435,7 +443,7 @@ export async function GET(request: NextRequest) {
currentTime += chapter.duration;
const endMs = Math.floor(currentTime * 1000);
metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${chapter.title}`);
metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`);
}
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));

View file

@ -23,6 +23,15 @@ function sanitizeFileStem(value: string): string {
.slice(0, 180);
}
export function escapeFFMetadata(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/=/g, '\\=')
.replace(/;/g, '\\;')
.replace(/#/g, '\\#')
.replace(/\r|\n/g, ' ');
}
export function encodeChapterTitleTag(index: number, title: string): string {
const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`;
const prefix = String(index + 1).padStart(4, '0');

View file

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
import { escapeFFMetadata } from '../../src/lib/server/audiobook';
test.describe('escapeFFMetadata', () => {
test('escapes special characters correctly', () => {
const input = 'Title with = ; # and backslash \\';
// Expected: Equal -> \=, Semicolon -> \;, Hash -> \#, Backslash -> \\
const expected = 'Title with \\= \\; \\# and backslash \\\\';
expect(escapeFFMetadata(input)).toBe(expected);
});
test('normalizes newlines to spaces', () => {
const input = 'Title with\nnewline and\rreturn';
const expected = 'Title with newline and return';
expect(escapeFFMetadata(input)).toBe(expected);
});
test('handles mixed special characters and newlines', () => {
const input = 'Line1\nLine2=Value;Comment#';
const expected = 'Line1 Line2\\=Value\\;Comment\\#';
expect(escapeFFMetadata(input)).toBe(expected);
});
test('returns empty string as-is', () => {
expect(escapeFFMetadata('')).toBe('');
});
test('returns safe string as-is', () => {
const input = 'Safe Title 123';
expect(escapeFFMetadata(input)).toBe(input);
});
});

View file

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { getMigratedDocumentFileName } from '@/lib/server/docstore';
import { getMigratedDocumentFileName } from '../../src/lib/server/docstore';
test.describe('Docstore Filename Safety', () => {
const id = 'a'.repeat(64); // Simulate sha256 hex ID