feat: Implement FFmpeg metadata escaping and robust chapter index validation for audiobook generation, alongside unit test reorganization.
This commit is contained in:
parent
9c941b57ed
commit
8dbf904a21
4 changed files with 58 additions and 9 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
32
tests/unit/audiobook.spec.ts
Normal file
32
tests/unit/audiobook.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue