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 { basename, join, resolve } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
|
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 { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
|
|
||||||
|
|
@ -178,6 +178,9 @@ export async function POST(request: NextRequest) {
|
||||||
// Create intermediate directory
|
// Create intermediate directory
|
||||||
await mkdir(intermediateDir, { recursive: true });
|
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 metaPath = join(intermediateDir, 'audiobook.meta.json');
|
||||||
const incomingSettings = data.settings;
|
const incomingSettings = data.settings;
|
||||||
let existingSettings: AudiobookGenerationSettings | null = null;
|
let existingSettings: AudiobookGenerationSettings | null = null;
|
||||||
|
|
@ -187,7 +190,9 @@ export async function POST(request: NextRequest) {
|
||||||
existingSettings = null;
|
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) {
|
if (incomingSettings) {
|
||||||
const mismatch =
|
const mismatch =
|
||||||
existingSettings.ttsProvider !== incomingSettings.ttsProvider ||
|
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));
|
|
||||||
}
|
}
|
||||||
|
// Note: We deliberately do NOT write the meta file here yet.
|
||||||
const existingChapters = await listStoredChapters(intermediateDir, request.signal);
|
// We wait until a chapter is successfully generated/saved below.
|
||||||
const existingFormats = new Set(existingChapters.map((c) => c.format));
|
const existingFormats = new Set(existingChapters.map((c) => c.format));
|
||||||
if (existingFormats.size > 1) {
|
if (existingFormats.size > 1) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
@ -224,10 +227,15 @@ export async function POST(request: NextRequest) {
|
||||||
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
|
const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1;
|
||||||
const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 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)
|
// Use provided chapter index or find the next available index robustly (handles gaps)
|
||||||
let chapterIndex: number;
|
let chapterIndex: number;
|
||||||
if (data.chapterIndex !== undefined) {
|
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 {
|
} else {
|
||||||
const indices = existingChapters.map((c) => c.index);
|
const indices = existingChapters.map((c) => c.index);
|
||||||
// Find smallest non-negative integer not present
|
// Find smallest non-negative integer not present
|
||||||
|
|
@ -435,7 +443,7 @@ export async function GET(request: NextRequest) {
|
||||||
currentTime += chapter.duration;
|
currentTime += chapter.duration;
|
||||||
const endMs = Math.floor(currentTime * 1000);
|
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'));
|
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,15 @@ function sanitizeFileStem(value: string): string {
|
||||||
.slice(0, 180);
|
.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 {
|
export function encodeChapterTitleTag(index: number, title: string): string {
|
||||||
const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`;
|
const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`;
|
||||||
const prefix = String(index + 1).padStart(4, '0');
|
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 { test, expect } from '@playwright/test';
|
||||||
import { getMigratedDocumentFileName } from '@/lib/server/docstore';
|
import { getMigratedDocumentFileName } from '../../src/lib/server/docstore';
|
||||||
|
|
||||||
test.describe('Docstore Filename Safety', () => {
|
test.describe('Docstore Filename Safety', () => {
|
||||||
const id = 'a'.repeat(64); // Simulate sha256 hex ID
|
const id = 'a'.repeat(64); // Simulate sha256 hex ID
|
||||||
Loading…
Reference in a new issue