openreader/src/app/api/tts/segments/manifest/route.ts
Richard R 265e1cb56a feat(tts,config,ui): add advanced segment preloading and block length controls
Introduce configurable segment preloading depth, sentence lookahead, and TTS segment max block length for both PDF and EPUB readers. Add new settings to user preferences, config context, and document settings UI. Refactor TTS segment splitting logic to support per-user max block length and propagate these options through PDF/EPUB adapters and TTS segment generation. Update API, manifest pagination, and segment deduplication for improved performance and correctness. Add tests for new NLP options and manifest logic.

BREAKING CHANGE: TTS segment splitting and manifest APIs now require max block length and preloading parameters; user config schema updated.
2026-05-07 12:26:08 -06:00

220 lines
7.5 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { ttsSegments } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import {
compareManifestSegments,
decodeManifestCursor,
dedupeManifestVariants,
encodeManifestCursor,
locatorGroupKey,
parseManifestPageSize,
} from '@/lib/server/tts/segments-manifest';
import type {
TTSSegmentLocator,
TTSSegmentRow,
TTSSegmentSettings,
TTSSegmentVariant,
TTSSegmentsManifestResponse,
} from '@/types/client';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
let raw: unknown = value;
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch { return null; }
}
if (!raw || typeof raw !== 'object') return null;
const rec = raw as Record<string, unknown>;
// Settings stored via buildTtsSegmentSettingsJson — accept either the runtime
// shape (ttsProvider/ttsModel/voice/nativeSpeed/ttsInstructions) or the
// canonical hash form (provider/model/voice/speed/instructions/format).
const ttsProvider = typeof rec.ttsProvider === 'string'
? rec.ttsProvider
: typeof rec.provider === 'string' ? rec.provider : null;
const ttsModel = typeof rec.ttsModel === 'string'
? rec.ttsModel
: typeof rec.model === 'string' ? rec.model : null;
const voice = typeof rec.voice === 'string' ? rec.voice : null;
const speedSource = rec.nativeSpeed ?? rec.speed;
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
if (!ttsProvider || !ttsModel || !voice) return null;
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
}
function parseLocator(value: unknown): TTSSegmentLocator | null {
if (!value) return null;
if (typeof value !== 'string') return value as TTSSegmentLocator;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object') return null;
return parsed as TTSSegmentLocator;
} catch {
return null;
}
}
function buildSegmentAudioUrls(documentId: string, segmentId: string): {
audioPresignUrl: string;
audioFallbackUrl: string;
} {
const encodedDocumentId = encodeURIComponent(documentId);
const encodedSegmentId = encodeURIComponent(segmentId);
return {
audioPresignUrl: `/api/tts/segments/audio/presign?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`,
audioFallbackUrl: `/api/tts/segments/audio/fallback?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`,
};
}
function isAbortLikeMessage(message: string | null | undefined): boolean {
if (!message) return false;
return /abort/i.test(message);
}
export async function GET(request: NextRequest) {
try {
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
const documentId = documentIdRaw?.trim().toLowerCase();
const limit = parseManifestPageSize(request.nextUrl.searchParams.get('limit'));
const cursor = decodeManifestCursor(request.nextUrl.searchParams.get('cursor'));
if (!documentId) {
return NextResponse.json({ error: 'Missing documentId' }, { status: 400 });
}
const scope = await resolveSegmentDocumentScope(request, documentId);
if (scope instanceof Response) return scope;
const rows = (await db
.select()
.from(ttsSegments)
.where(and(
eq(ttsSegments.userId, scope.storageUserId),
eq(ttsSegments.documentId, documentId),
eq(ttsSegments.documentVersion, scope.documentVersion),
))
.orderBy(asc(ttsSegments.segmentIndex), asc(ttsSegments.updatedAt))) as Array<{
segmentId: string;
userId: string;
documentId: string;
readerType: string;
documentVersion: number;
segmentIndex: number;
locatorJson: string | null;
settingsHash: string;
settingsJson: unknown;
textHash: string;
textLength: number;
audioKey: string | null;
audioFormat: string;
durationMs: number | null;
alignmentJson: string | null;
status: string;
error: string | null;
createdAt: number | null;
updatedAt: number | null;
}>;
const grouped = new Map<string, Omit<TTSSegmentRow, 'variants'> & {
variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>;
}>();
for (const row of rows) {
const locator = parseLocator(row.locatorJson);
const groupKey = `${row.segmentIndex}|${locatorGroupKey(locator)}`;
let entry = grouped.get(groupKey);
if (!entry) {
entry = {
segmentIndex: row.segmentIndex,
locator,
variants: [],
};
grouped.set(groupKey, entry);
} else if (!entry.locator) {
entry.locator = locator;
}
let alignmentWordCount = 0;
if (row.alignmentJson) {
try {
const parsed = JSON.parse(row.alignmentJson) as { words?: unknown[] };
alignmentWordCount = Array.isArray(parsed?.words) ? parsed.words.length : 0;
} catch {
alignmentWordCount = 0;
}
}
const status: TTSSegmentVariant['status'] = row.status === 'completed'
? 'completed'
: row.status === 'error' && !isAbortLikeMessage(row.error)
? 'error'
: 'pending';
const audioUrls = row.status === 'completed' && row.audioKey
? buildSegmentAudioUrls(documentId, row.segmentId)
: { audioPresignUrl: null, audioFallbackUrl: null };
entry.variants.push({
dedupeKey: `settings:${row.settingsHash}`,
variant: {
segmentId: row.segmentId,
settings: parseSettingsValue(row.settingsJson),
audioPresignUrl: audioUrls.audioPresignUrl,
audioFallbackUrl: audioUrls.audioFallbackUrl,
durationMs: row.durationMs,
status,
textLength: row.textLength,
alignmentWordCount,
audioKey: row.audioKey,
updatedAt: row.updatedAt,
},
});
}
const segments = Array.from(grouped.entries())
.map(([groupKey, segment]) => ({
groupKey,
segmentIndex: segment.segmentIndex,
locator: segment.locator,
variants: dedupeManifestVariants(segment.variants),
}))
.sort(compareManifestSegments);
let startIndex = 0;
if (cursor) {
const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor);
if (cursorIndex < 0) {
return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 });
}
if (cursorIndex >= 0) startIndex = cursorIndex + 1;
}
const page = segments.slice(startIndex, startIndex + limit);
const hasMore = startIndex + page.length < segments.length;
const nextCursor = hasMore && page.length > 0
? encodeManifestCursor(page[page.length - 1].groupKey)
: null;
const response: TTSSegmentsManifestResponse = {
documentId,
segments: page.map((segment) => ({
segmentIndex: segment.segmentIndex,
locator: segment.locator,
variants: segment.variants,
})),
nextCursor,
hasMore,
};
return NextResponse.json(response);
} catch (error) {
console.error('Error listing TTS segments manifest:', error);
return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 });
}
}