diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 9d6d0f3..15aa4cb 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -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')); diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index 2ef5c7a..f32a2a5 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -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'); diff --git a/tests/unit/audiobook.spec.ts b/tests/unit/audiobook.spec.ts new file mode 100644 index 0000000..1d990ad --- /dev/null +++ b/tests/unit/audiobook.spec.ts @@ -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); + }); +}); diff --git a/tests/docstore_unit.spec.ts b/tests/unit/docstore.spec.ts similarity index 96% rename from tests/docstore_unit.spec.ts rename to tests/unit/docstore.spec.ts index ccd4879..55a57e7 100644 --- a/tests/docstore_unit.spec.ts +++ b/tests/unit/docstore.spec.ts @@ -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