refactor(tts,db,manifest): split tts_segments into normalized entry and variant tables

Redesign TTS segment storage by decomposing the `tts_segments` table into two normalized tables: `tts_segment_entries` for unique segment identity and locator projection, and `tts_segment_variants` for per-settings audio variants. Update schema, queries, and API routes to use the new structure, including manifest pagination and segment resolution logic. Refactor segment locator handling to use stable projections and manifest cursors. Migrate tests and data cleanup utilities to support the new model.

BREAKING CHANGE: Database schema for TTS segments is now split into entry and variant tables; all code and consumers must use the new structure. Persisted data and APIs relying on the old `tts_segments` table are incompatible.
This commit is contained in:
Richard R 2026-05-12 17:44:57 -06:00
parent f9adba791d
commit 8488ad37e1
21 changed files with 3853 additions and 298 deletions

View file

@ -0,0 +1,50 @@
CREATE TABLE "tts_segment_entries" (
"segment_entry_id" text NOT NULL,
"user_id" text NOT NULL,
"document_id" text NOT NULL,
"reader_type" text NOT NULL,
"document_version" bigint NOT NULL,
"segment_index" integer NOT NULL,
"segment_key" text,
"locator_reader_rank" integer NOT NULL,
"locator_reader_type" text NOT NULL,
"locator_page" integer NOT NULL,
"locator_spine_index" integer NOT NULL,
"locator_spine_href" text NOT NULL,
"locator_char_offset" integer NOT NULL,
"locator_location" text NOT NULL,
"locator_identity_key" text NOT NULL,
"text_hash" text NOT NULL,
"text_length" integer DEFAULT 0 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "tts_segment_entries_segment_entry_id_user_id_pk" PRIMARY KEY("segment_entry_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "tts_segment_variants" (
"segment_id" text NOT NULL,
"user_id" text NOT NULL,
"segment_entry_id" text NOT NULL,
"settings_hash" text NOT NULL,
"settings_json" jsonb NOT NULL,
"audio_key" text,
"audio_format" text DEFAULT 'mp3' NOT NULL,
"duration_ms" integer,
"alignment_json" text,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "tts_segment_variants_segment_id_user_id_pk" PRIMARY KEY("segment_id","user_id")
);
--> statement-breakpoint
DROP TABLE "tts_segments" CASCADE;--> statement-breakpoint
ALTER TABLE "tts_segment_entries" ADD CONSTRAINT "tts_segment_entries_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tts_segment_variants" ADD CONSTRAINT "tts_segment_variants_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tts_segment_variants" ADD CONSTRAINT "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk" FOREIGN KEY ("segment_entry_id","user_id") REFERENCES "public"."tts_segment_entries"("segment_entry_id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_manifest_sort" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version","locator_reader_rank","locator_spine_index","locator_char_offset","locator_spine_href","locator_page","locator_location","segment_index","locator_identity_key");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_manifest_group" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version","segment_index","locator_identity_key");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_entries_scope" ON "tts_segment_entries" USING btree ("user_id","document_id","document_version");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_entry" ON "tts_segment_variants" USING btree ("user_id","segment_entry_id","updated_at");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_status" ON "tts_segment_variants" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "idx_tts_segment_variants_unique_settings" ON "tts_segment_variants" USING btree ("user_id","segment_entry_id","settings_hash");

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1778502597852, "when": 1778502597852,
"tag": "0002_add_segment_key_to_tts_segments", "tag": "0002_add_segment_key_to_tts_segments",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1778627816569,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,50 @@
CREATE TABLE `tts_segment_entries` (
`segment_entry_id` text NOT NULL,
`user_id` text NOT NULL,
`document_id` text NOT NULL,
`reader_type` text NOT NULL,
`document_version` integer NOT NULL,
`segment_index` integer NOT NULL,
`segment_key` text,
`locator_reader_rank` integer NOT NULL,
`locator_reader_type` text NOT NULL,
`locator_page` integer NOT NULL,
`locator_spine_index` integer NOT NULL,
`locator_spine_href` text NOT NULL,
`locator_char_offset` integer NOT NULL,
`locator_location` text NOT NULL,
`locator_identity_key` text NOT NULL,
`text_hash` text NOT NULL,
`text_length` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`segment_entry_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_manifest_sort` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`,`locator_reader_rank`,`locator_spine_index`,`locator_char_offset`,`locator_spine_href`,`locator_page`,`locator_location`,`segment_index`,`locator_identity_key`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_manifest_group` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`,`segment_index`,`locator_identity_key`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_entries_scope` ON `tts_segment_entries` (`user_id`,`document_id`,`document_version`);--> statement-breakpoint
CREATE TABLE `tts_segment_variants` (
`segment_id` text NOT NULL,
`user_id` text NOT NULL,
`segment_entry_id` text NOT NULL,
`settings_hash` text NOT NULL,
`settings_json` text NOT NULL,
`audio_key` text,
`audio_format` text DEFAULT 'mp3' NOT NULL,
`duration_ms` integer,
`alignment_json` text,
`status` text DEFAULT 'pending' NOT NULL,
`error` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`segment_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`segment_entry_id`,`user_id`) REFERENCES `tts_segment_entries`(`segment_entry_id`,`user_id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_entry` ON `tts_segment_variants` (`user_id`,`segment_entry_id`,`updated_at`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_status` ON `tts_segment_variants` (`user_id`,`status`);--> statement-breakpoint
CREATE INDEX `idx_tts_segment_variants_unique_settings` ON `tts_segment_variants` (`user_id`,`segment_entry_id`,`settings_hash`);--> statement-breakpoint
DROP TABLE `tts_segments`;

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1778502597591, "when": 1778502597591,
"tag": "0002_add_segment_key_to_tts_segments", "tag": "0002_add_segment_key_to_tts_segments",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1778627810476,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
} }
] ]
} }

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegments } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
@ -27,22 +27,26 @@ export async function POST(request: NextRequest) {
const rows = (await db const rows = (await db
.select({ .select({
segmentId: ttsSegments.segmentId, segmentId: ttsSegmentVariants.segmentId,
audioKey: ttsSegments.audioKey, audioKey: ttsSegmentVariants.audioKey,
}) })
.from(ttsSegments) .from(ttsSegmentVariants)
.innerJoin(ttsSegmentEntries, and(
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
))
.where(and( .where(and(
eq(ttsSegments.userId, scope.storageUserId), eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegments.documentId, parsed.documentId), eq(ttsSegmentEntries.documentId, parsed.documentId),
eq(ttsSegments.documentVersion, scope.documentVersion), eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
))) as Array<{ segmentId: string; audioKey: string | null }>; ))) as Array<{ segmentId: string; audioKey: string | null }>;
await db await db
.delete(ttsSegments) .delete(ttsSegmentEntries)
.where(and( .where(and(
eq(ttsSegments.userId, scope.storageUserId), eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegments.documentId, parsed.documentId), eq(ttsSegmentEntries.documentId, parsed.documentId),
eq(ttsSegments.documentVersion, scope.documentVersion), eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
)); ));
const audioKeys = rows const audioKeys = rows

View file

@ -1,25 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray, ne } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegments } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3'; import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
import { generateTTSBuffer } from '@/lib/server/tts/generate'; import { generateTTSBuffer } from '@/lib/server/tts/generate';
import { import {
deleteTtsSegmentAudioObjects,
getTtsSegmentAudioObject, getTtsSegmentAudioObject,
putTtsSegmentAudioObject, putTtsSegmentAudioObject,
} from '@/lib/server/tts/segments-blobstore'; } from '@/lib/server/tts/segments-blobstore';
import { import {
buildTtsSegmentAudioKey, buildTtsSegmentAudioKey,
buildTtsSegmentEntryId,
buildTtsSegmentId, buildTtsSegmentId,
buildTtsSegmentSettingsHash, buildTtsSegmentSettingsHash,
buildTtsSegmentSettingsJson, buildTtsSegmentSettingsJson,
buildTtsSegmentTextHash, buildTtsSegmentTextHash,
canonicalLocatorJson,
canonicalizeLocatorJsonString,
locatorFingerprint, locatorFingerprint,
normalizeLocator, normalizeLocator,
normalizeSegmentText, normalizeSegmentText,
projectSegmentLocator,
probeAudioDurationMsFromBuffer, probeAudioDurationMsFromBuffer,
} from '@/lib/server/tts/segments'; } from '@/lib/server/tts/segments';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
@ -140,67 +139,24 @@ function isAbortLikeError(error: unknown): boolean {
return /abort/i.test(message); return /abort/i.test(message);
} }
async function cleanupStaleCanonicalVariants(input: { async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Promise<void> {
userId: string; const stillReferenced = await db
documentId: string; .select({ segmentId: ttsSegmentVariants.segmentId })
documentVersion: number; .from(ttsSegmentVariants)
segmentIndex: number;
activeLocatorJson: string | null;
activeSegmentId: string;
activeSettingsHash: string;
}): Promise<void> {
const staleRows = (await db
.select({
segmentId: ttsSegments.segmentId,
settingsHash: ttsSegments.settingsHash,
audioKey: ttsSegments.audioKey,
locatorJson: ttsSegments.locatorJson,
})
.from(ttsSegments)
.where(and( .where(and(
eq(ttsSegments.userId, input.userId), eq(ttsSegmentVariants.userId, userId),
eq(ttsSegments.documentId, input.documentId), eq(ttsSegmentVariants.segmentEntryId, segmentEntryId),
eq(ttsSegments.documentVersion, input.documentVersion), ))
eq(ttsSegments.segmentIndex, input.segmentIndex), .limit(1);
ne(ttsSegments.segmentId, input.activeSegmentId),
))) as Array<{
segmentId: string;
settingsHash: string;
audioKey: string | null;
locatorJson: string | null;
}>;
const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson); if (stillReferenced.length > 0) return;
const staleRowsForSettings = staleRows.filter((row) =>
row.settingsHash === input.activeSettingsHash
&& canonicalizeLocatorJsonString(row.locatorJson) === activeCanonicalLocator,
);
const staleIds = staleRowsForSettings.map((row) => row.segmentId);
if (staleIds.length === 0) return;
await db await db
.delete(ttsSegments) .delete(ttsSegmentEntries)
.where(and( .where(and(
eq(ttsSegments.userId, input.userId), eq(ttsSegmentEntries.userId, userId),
inArray(ttsSegments.segmentId, staleIds), eq(ttsSegmentEntries.segmentEntryId, segmentEntryId),
)); ));
const staleAudioKeys = Array.from(new Set(staleRowsForSettings
.map((row) => row.audioKey)
.filter((key): key is string => Boolean(key))));
if (staleAudioKeys.length > 0) {
try {
await deleteTtsSegmentAudioObjects(staleAudioKeys);
} catch (error) {
console.warn('Failed deleting stale TTS segment audio objects:', {
documentId: input.documentId,
userId: input.userId,
segmentIndex: input.segmentIndex,
error: error instanceof Error ? error.message : String(error),
});
}
}
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -223,11 +179,16 @@ export async function POST(request: NextRequest) {
const storagePrefix = getS3Config().prefix; const storagePrefix = getS3Config().prefix;
const secret = textHmacSecret(); const secret = textHmacSecret();
let invalidLocatorIndex = -1;
const normalized = parsed.segments const normalized = parsed.segments
.map((segment) => { .map((segment, index) => {
const text = normalizeSegmentText(segment.text); const text = normalizeSegmentText(segment.text);
if (!text) return null; if (!text) return null;
const locator = normalizeLocator(segment.locator); const locator = normalizeLocator(segment.locator);
if (!locator) {
invalidLocatorIndex = index;
return null;
}
const locatorHash = locatorFingerprint(locator); const locatorHash = locatorFingerprint(locator);
const segmentId = buildTtsSegmentId({ const segmentId = buildTtsSegmentId({
documentId: parsed.documentId, documentId: parsed.documentId,
@ -249,6 +210,13 @@ export async function POST(request: NextRequest) {
}) })
.filter((value): value is NonNullable<typeof value> => Boolean(value)); .filter((value): value is NonNullable<typeof value> => Boolean(value));
if (invalidLocatorIndex >= 0) {
return NextResponse.json(
{ error: `Invalid or unsupported segment locator at index ${invalidLocatorIndex}` },
{ status: 400 },
);
}
if (normalized.length === 0) { if (normalized.length === 0) {
return NextResponse.json({ error: 'No valid non-empty segments provided' }, { status: 400 }); return NextResponse.json({ error: 'No valid non-empty segments provided' }, { status: 400 });
} }
@ -256,19 +224,16 @@ export async function POST(request: NextRequest) {
const ids = normalized.map((segment) => segment.segmentId); const ids = normalized.map((segment) => segment.segmentId);
const rows = (await db const rows = (await db
.select() .select()
.from(ttsSegments) .from(ttsSegmentVariants)
.where(and(eq(ttsSegments.userId, scope.storageUserId), inArray(ttsSegments.segmentId, ids)))) as Array<{ .where(and(
eq(ttsSegmentVariants.userId, scope.storageUserId),
inArray(ttsSegmentVariants.segmentId, ids),
))) as Array<{
segmentId: string; segmentId: string;
userId: string; userId: string;
documentId: string; segmentEntryId: string;
readerType: string;
documentVersion: number;
segmentIndex: number;
locatorJson: string | null;
settingsHash: string; settingsHash: string;
settingsJson: unknown; settingsJson: unknown;
textHash: string;
textLength: number;
audioKey: string | null; audioKey: string | null;
audioFormat: string; audioFormat: string;
durationMs: number | null; durationMs: number | null;
@ -283,25 +248,74 @@ export async function POST(request: NextRequest) {
const manifest: TTSSegmentManifestItem[] = []; const manifest: TTSSegmentManifestItem[] = [];
for (const segment of normalized) { for (const segment of normalized) {
const existing = existingById.get(segment.segmentId); const locatorProjection = projectSegmentLocator(segment.locator);
const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim()
? segment.original.segmentKey.trim()
: null;
const segmentEntryId = buildTtsSegmentEntryId({
documentId: parsed.documentId,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorIdentityKey: locatorProjection.locatorIdentityKey,
textHash: segment.textHash,
});
if (existing?.status === 'completed' && existing.audioKey) { await db
await cleanupStaleCanonicalVariants({ .insert(ttsSegmentEntries)
.values({
segmentEntryId,
userId: scope.storageUserId, userId: scope.storageUserId,
documentId: parsed.documentId, documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion, documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
activeLocatorJson: existing.locatorJson, segmentKey: segmentKeyForRow,
activeSegmentId: segment.segmentId, ...locatorProjection,
activeSettingsHash: settingsHash, textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
})
.onConflictDoUpdate({
target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
set: {
documentId: parsed.documentId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
...locatorProjection,
textHash: segment.textHash,
textLength: segment.text.length,
updatedAt: nowMs,
},
}); });
const existing = existingById.get(segment.segmentId);
const movedFromEntryId = existing && existing.segmentEntryId !== segmentEntryId
? existing.segmentEntryId
: null;
if (existing?.status === 'completed' && existing.audioKey) {
if (movedFromEntryId) {
await db
.update(ttsSegmentVariants)
.set({
segmentEntryId,
settingsJson,
updatedAt: nowMs,
})
.where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId);
}
let alignment = existing.alignmentJson let alignment = existing.alignmentJson
? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment']) ? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment'])
: null; : null;
const locator = existing.locatorJson const locator = segment.locator;
? (JSON.parse(existing.locatorJson) as TTSSegmentManifestItem['locator'])
: null;
// Self-heal transient Whisper failures: if audio exists but alignment was // Self-heal transient Whisper failures: if audio exists but alignment was
// previously unavailable, retry alignment using the current segment text. // previously unavailable, retry alignment using the current segment text.
@ -319,12 +333,15 @@ export async function POST(request: NextRequest) {
if (alignment) { if (alignment) {
await db await db
.update(ttsSegments) .update(ttsSegmentVariants)
.set({ .set({
alignmentJson: JSON.stringify(alignment), alignmentJson: JSON.stringify(alignment),
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); .where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
} }
} catch (alignError) { } catch (alignError) {
console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', { console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', {
@ -337,8 +354,8 @@ export async function POST(request: NextRequest) {
manifest.push({ manifest.push({
segmentId: segment.segmentId, segmentId: segment.segmentId,
segmentIndex: existing.segmentIndex, segmentIndex: segment.original.segmentIndex,
segmentKey: segment.original.segmentKey ?? null, segmentKey: segmentKeyForRow,
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
durationMs: existing.durationMs ?? 0, durationMs: existing.durationMs ?? 0,
alignment, alignment,
@ -358,25 +375,14 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId, segmentId: segment.segmentId,
}); });
const segmentLocatorJson = canonicalLocatorJson(segment.locator);
const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim()
? segment.original.segmentKey.trim()
: null;
await db await db
.insert(ttsSegments) .insert(ttsSegmentVariants)
.values({ .values({
segmentId: segment.segmentId, segmentId: segment.segmentId,
userId: scope.storageUserId, userId: scope.storageUserId,
documentId: parsed.documentId, segmentEntryId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorJson: segmentLocatorJson,
settingsHash, settingsHash,
settingsJson, settingsJson,
textHash: segment.textHash,
textLength: segment.text.length,
audioKey, audioKey,
audioFormat: 'mp3', audioFormat: 'mp3',
status: 'pending', status: 'pending',
@ -384,18 +390,11 @@ export async function POST(request: NextRequest) {
updatedAt: nowMs, updatedAt: nowMs,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [ttsSegments.segmentId, ttsSegments.userId], target: [ttsSegmentVariants.segmentId, ttsSegmentVariants.userId],
set: { set: {
documentId: parsed.documentId, segmentEntryId,
readerType: scope.readerType,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
segmentKey: segmentKeyForRow,
locatorJson: segmentLocatorJson,
settingsHash, settingsHash,
settingsJson, settingsJson,
textHash: segment.textHash,
textLength: segment.text.length,
audioKey, audioKey,
audioFormat: 'mp3', audioFormat: 'mp3',
status: 'pending', status: 'pending',
@ -404,6 +403,10 @@ export async function POST(request: NextRequest) {
}, },
}); });
if (movedFromEntryId) {
await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId);
}
try { try {
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) { if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
const charCount = segment.text.length; const charCount = segment.text.length;
@ -493,7 +496,7 @@ export async function POST(request: NextRequest) {
} }
await db await db
.update(ttsSegments) .update(ttsSegmentVariants)
.set({ .set({
durationMs, durationMs,
alignmentJson: alignment ? JSON.stringify(alignment) : null, alignmentJson: alignment ? JSON.stringify(alignment) : null,
@ -501,22 +504,15 @@ export async function POST(request: NextRequest) {
error: null, error: null,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); .where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
await cleanupStaleCanonicalVariants({ eq(ttsSegmentVariants.userId, scope.storageUserId),
userId: scope.storageUserId, ));
documentId: parsed.documentId,
documentVersion: scope.documentVersion,
segmentIndex: segment.original.segmentIndex,
activeLocatorJson: canonicalLocatorJson(segment.locator),
activeSegmentId: segment.segmentId,
activeSettingsHash: settingsHash,
});
manifest.push({ manifest.push({
segmentId: segment.segmentId, segmentId: segment.segmentId,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
segmentKey: segment.original.segmentKey ?? null, segmentKey: segmentKeyForRow,
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
durationMs, durationMs,
alignment, alignment,
@ -527,18 +523,21 @@ export async function POST(request: NextRequest) {
const message = error instanceof Error ? error.message : 'Failed to generate segment'; const message = error instanceof Error ? error.message : 'Failed to generate segment';
const aborted = isAbortLikeError(error); const aborted = isAbortLikeError(error);
await db await db
.update(ttsSegments) .update(ttsSegmentVariants)
.set({ .set({
status: aborted ? 'pending' : 'error', status: aborted ? 'pending' : 'error',
error: aborted ? null : message, error: aborted ? null : message,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); .where(and(
eq(ttsSegmentVariants.segmentId, segment.segmentId),
eq(ttsSegmentVariants.userId, scope.storageUserId),
));
manifest.push({ manifest.push({
segmentId: segment.segmentId, segmentId: segment.segmentId,
segmentIndex: segment.original.segmentIndex, segmentIndex: segment.original.segmentIndex,
segmentKey: segment.original.segmentKey ?? null, segmentKey: segmentKeyForRow,
audioPresignUrl: null, audioPresignUrl: null,
audioFallbackUrl: null, audioFallbackUrl: null,
durationMs: 0, durationMs: 0,

View file

@ -1,15 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { and, asc, eq } from 'drizzle-orm'; import { and, asc, eq, gt, inArray, or } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegments } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { import {
compareManifestSegments,
decodeManifestCursor, decodeManifestCursor,
dedupeManifestVariants, dedupeManifestVariants,
encodeManifestCursor, encodeManifestCursor,
locatorIdentityKey,
parseManifestPageSize, parseManifestPageSize,
type TTSSegmentManifestCursor,
} from '@/lib/server/tts/segments-manifest'; } from '@/lib/server/tts/segments-manifest';
import type { import type {
TTSSegmentLocator, TTSSegmentLocator,
@ -22,6 +21,21 @@ import type {
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
type ManifestGroupRow = {
segmentEntryId: string;
segmentIndex: number;
segmentKey: string | null;
textLength: number;
locatorReaderRank: number;
locatorReaderType: string;
locatorPage: number;
locatorSpineIndex: number;
locatorSpineHref: string;
locatorCharOffset: number;
locatorLocation: string;
locatorIdentityKey: string;
};
function parseSettingsValue(value: unknown): TTSSegmentSettings | null { function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
let raw: unknown = value; let raw: unknown = value;
if (typeof raw === 'string') { if (typeof raw === 'string') {
@ -30,9 +44,6 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') return null;
const rec = raw as Record<string, unknown>; 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' const ttsProvider = typeof rec.ttsProvider === 'string'
? rec.ttsProvider ? rec.ttsProvider
: typeof rec.provider === 'string' ? rec.provider : null; : typeof rec.provider === 'string' ? rec.provider : null;
@ -49,16 +60,22 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions }; return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions };
} }
function parseLocator(value: unknown): TTSSegmentLocator | null { function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
if (!value) return null; if (row.locatorReaderType === 'epub' && row.locatorSpineIndex >= 0 && row.locatorCharOffset >= 0 && row.locatorSpineHref) {
if (typeof value !== 'string') return value as TTSSegmentLocator; return {
try { readerType: 'epub',
const parsed = JSON.parse(value); spineIndex: row.locatorSpineIndex,
if (!parsed || typeof parsed !== 'object') return null; spineHref: row.locatorSpineHref,
return parsed as TTSSegmentLocator; charOffset: row.locatorCharOffset,
} catch { };
return null;
} }
if (row.locatorReaderType === 'pdf' && row.locatorPage >= 1) {
return { readerType: 'pdf', page: row.locatorPage };
}
if (row.locatorReaderType === 'html' && row.locatorLocation) {
return { readerType: 'html', location: row.locatorLocation };
}
return null;
} }
function buildSegmentAudioUrls(documentId: string, segmentId: string): { function buildSegmentAudioUrls(documentId: string, segmentId: string): {
@ -78,6 +95,86 @@ function isAbortLikeMessage(message: string | null | undefined): boolean {
return /abort/i.test(message); return /abort/i.test(message);
} }
function buildKeysetWhere(cursor: TTSSegmentManifestCursor) {
return or(
gt(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
gt(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
gt(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
gt(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
gt(ttsSegmentEntries.locatorPage, cursor.locatorPage),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
gt(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
gt(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
gt(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey),
),
and(
eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank),
eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex),
eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset),
eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref),
eq(ttsSegmentEntries.locatorPage, cursor.locatorPage),
eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation),
eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex),
eq(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey),
gt(ttsSegmentEntries.segmentEntryId, cursor.segmentEntryId),
),
);
}
function cursorFromGroupRow(row: ManifestGroupRow): TTSSegmentManifestCursor {
return {
locatorReaderRank: row.locatorReaderRank,
locatorSpineIndex: row.locatorSpineIndex,
locatorCharOffset: row.locatorCharOffset,
locatorSpineHref: row.locatorSpineHref,
locatorPage: row.locatorPage,
locatorLocation: row.locatorLocation,
segmentIndex: row.segmentIndex,
locatorIdentityKey: row.locatorIdentityKey,
segmentEntryId: row.segmentEntryId,
};
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const documentIdRaw = request.nextUrl.searchParams.get('documentId'); const documentIdRaw = request.nextUrl.searchParams.get('documentId');
@ -91,27 +188,92 @@ export async function GET(request: NextRequest) {
const scope = await resolveSegmentDocumentScope(request, documentId); const scope = await resolveSegmentDocumentScope(request, documentId);
if (scope instanceof Response) return scope; if (scope instanceof Response) return scope;
const rows = (await db const scopeWhere = and(
.select() eq(ttsSegmentEntries.userId, scope.storageUserId),
.from(ttsSegments) eq(ttsSegmentEntries.documentId, documentId),
.where(and( eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
eq(ttsSegments.userId, scope.storageUserId), );
eq(ttsSegments.documentId, documentId),
eq(ttsSegments.documentVersion, scope.documentVersion), const groupWhere = cursor
? and(scopeWhere, buildKeysetWhere(cursor))
: scopeWhere;
const groupedRows = (await db
.select({
segmentEntryId: ttsSegmentEntries.segmentEntryId,
segmentIndex: ttsSegmentEntries.segmentIndex,
segmentKey: ttsSegmentEntries.segmentKey,
textLength: ttsSegmentEntries.textLength,
locatorReaderRank: ttsSegmentEntries.locatorReaderRank,
locatorReaderType: ttsSegmentEntries.locatorReaderType,
locatorPage: ttsSegmentEntries.locatorPage,
locatorSpineIndex: ttsSegmentEntries.locatorSpineIndex,
locatorSpineHref: ttsSegmentEntries.locatorSpineHref,
locatorCharOffset: ttsSegmentEntries.locatorCharOffset,
locatorLocation: ttsSegmentEntries.locatorLocation,
locatorIdentityKey: ttsSegmentEntries.locatorIdentityKey,
})
.from(ttsSegmentEntries)
.innerJoin(ttsSegmentVariants, and(
eq(ttsSegmentVariants.segmentEntryId, ttsSegmentEntries.segmentEntryId),
eq(ttsSegmentVariants.userId, ttsSegmentEntries.userId),
)) ))
.orderBy(asc(ttsSegments.segmentIndex), asc(ttsSegments.updatedAt))) as Array<{ .where(groupWhere)
.groupBy(
ttsSegmentEntries.segmentEntryId,
ttsSegmentEntries.segmentIndex,
ttsSegmentEntries.segmentKey,
ttsSegmentEntries.textLength,
ttsSegmentEntries.locatorReaderRank,
ttsSegmentEntries.locatorReaderType,
ttsSegmentEntries.locatorPage,
ttsSegmentEntries.locatorSpineIndex,
ttsSegmentEntries.locatorSpineHref,
ttsSegmentEntries.locatorCharOffset,
ttsSegmentEntries.locatorLocation,
ttsSegmentEntries.locatorIdentityKey,
)
.orderBy(
asc(ttsSegmentEntries.locatorReaderRank),
asc(ttsSegmentEntries.locatorSpineIndex),
asc(ttsSegmentEntries.locatorCharOffset),
asc(ttsSegmentEntries.locatorSpineHref),
asc(ttsSegmentEntries.locatorPage),
asc(ttsSegmentEntries.locatorLocation),
asc(ttsSegmentEntries.segmentIndex),
asc(ttsSegmentEntries.locatorIdentityKey),
asc(ttsSegmentEntries.segmentEntryId),
)
.limit(limit + 1)) as ManifestGroupRow[];
const hasMore = groupedRows.length > limit;
const pageGroups = hasMore ? groupedRows.slice(0, limit) : groupedRows;
if (pageGroups.length === 0) {
const emptyResponse: TTSSegmentsManifestResponse = {
documentId,
segments: [],
nextCursor: null,
hasMore: false,
};
return NextResponse.json(emptyResponse);
}
const entryIds = pageGroups.map((row) => row.segmentEntryId);
const entryById = new Map(pageGroups.map((entry) => [entry.segmentEntryId, entry]));
const variantRows = (await db
.select()
.from(ttsSegmentVariants)
.where(and(
eq(ttsSegmentVariants.userId, scope.storageUserId),
inArray(ttsSegmentVariants.segmentEntryId, entryIds),
))
.orderBy(asc(ttsSegmentVariants.segmentEntryId), asc(ttsSegmentVariants.updatedAt))) as Array<{
segmentId: string; segmentId: string;
userId: string; userId: string;
documentId: string; segmentEntryId: string;
readerType: string;
documentVersion: number;
segmentIndex: number;
segmentKey: string | null;
locatorJson: string | null;
settingsHash: string; settingsHash: string;
settingsJson: unknown; settingsJson: unknown;
textHash: string;
textLength: number;
audioKey: string | null; audioKey: string | null;
audioFormat: string; audioFormat: string;
durationMs: number | null; durationMs: number | null;
@ -126,26 +288,21 @@ export async function GET(request: NextRequest) {
variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>; variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>;
}>(); }>();
for (const row of rows) { for (const row of variantRows) {
const locator = parseLocator(row.locatorJson); const entryMeta = entryById.get(row.segmentEntryId);
// Use the per-row identity key (not the coarse sidebar group key) so two if (!entryMeta) continue;
// persisted rows in the same chapter at different `charOffset`s remain const key = row.segmentEntryId;
// distinct entries instead of collapsing into one bucket whose locator const locator = locatorFromProjection(entryMeta);
// only reflects the first row seen.
const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`;
let entry = grouped.get(groupKey); let entry = grouped.get(key);
if (!entry) { if (!entry) {
entry = { entry = {
segmentIndex: row.segmentIndex, segmentIndex: entryMeta.segmentIndex,
segmentKey: row.segmentKey, segmentKey: entryMeta.segmentKey,
locator, locator,
variants: [], variants: [],
}; };
grouped.set(groupKey, entry); grouped.set(key, entry);
} else {
if (!entry.locator) entry.locator = locator;
if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey;
} }
let alignmentWordCount = 0; let alignmentWordCount = 0;
@ -177,7 +334,7 @@ export async function GET(request: NextRequest) {
audioFallbackUrl: audioUrls.audioFallbackUrl, audioFallbackUrl: audioUrls.audioFallbackUrl,
durationMs: row.durationMs, durationMs: row.durationMs,
status, status,
textLength: row.textLength, textLength: entryMeta.textLength,
alignmentWordCount, alignmentWordCount,
audioKey: row.audioKey, audioKey: row.audioKey,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
@ -185,39 +342,25 @@ export async function GET(request: NextRequest) {
}); });
} }
const segments = Array.from(grouped.entries()) const segments = pageGroups
.map(([groupKey, segment]) => ({ .map((row) => {
groupKey, const key = row.segmentEntryId;
segmentIndex: segment.segmentIndex, const entry = grouped.get(key);
segmentKey: segment.segmentKey, return {
locator: segment.locator, segmentIndex: row.segmentIndex,
variants: dedupeManifestVariants(segment.variants), segmentKey: row.segmentKey ?? null,
})) locator: entry?.locator || locatorFromProjection(row),
.sort(compareManifestSegments); variants: dedupeManifestVariants(entry?.variants || []),
};
});
let startIndex = 0; const nextCursor = hasMore
if (cursor) { ? encodeManifestCursor(cursorFromGroupRow(pageGroups[pageGroups.length - 1]))
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; : null;
const response: TTSSegmentsManifestResponse = { const response: TTSSegmentsManifestResponse = {
documentId, documentId,
segments: page.map((segment) => ({ segments,
segmentIndex: segment.segmentIndex,
segmentKey: segment.segmentKey ?? null,
locator: segment.locator,
variants: segment.variants,
})),
nextCursor, nextCursor,
hasMore, hasMore,
}; };

View file

@ -44,7 +44,6 @@ type FetchState =
| { | {
kind: 'ready'; kind: 'ready';
data: TTSSegmentRow[]; data: TTSSegmentRow[];
fetchedAt: number;
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; hasMore: boolean;
loadingMore: boolean; loadingMore: boolean;
@ -290,7 +289,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
return { return {
kind: 'ready', kind: 'ready',
data: merged, data: merged,
fetchedAt: Date.now(),
nextCursor: data.nextCursor, nextCursor: data.nextCursor,
hasMore: data.hasMore, hasMore: data.hasMore,
loadingMore: false, loadingMore: false,

View file

@ -177,6 +177,11 @@ type EpubLocationPreloadCandidate = {
cacheKey: string; cacheKey: string;
}; };
type PersistResolution = {
segments: TTSSegmentInput[];
sourceIndices: number[];
};
type JumpResolutionInput = { type JumpResolutionInput = {
isEPUB: boolean; isEPUB: boolean;
newSentenceCount: number; newSentenceCount: number;
@ -313,6 +318,9 @@ const locatorForLocation = (
if (typeof location === 'string') { if (typeof location === 'string') {
return { location, readerType }; return { location, readerType };
} }
if (readerType === 'html') {
return { location: String(location || 1), readerType };
}
return { page: Math.max(1, Math.floor(Number(location || 1))), readerType }; return { page: Math.max(1, Math.floor(Number(location || 1))), readerType };
}; };
@ -425,13 +433,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
*/ */
const resolveSegmentsForPersist = useCallback(async ( const resolveSegmentsForPersist = useCallback(async (
segments: TTSSegmentInput[], segments: TTSSegmentInput[],
): Promise<TTSSegmentInput[]> => { ): Promise<PersistResolution> => {
const resolver = epubLocatorResolverRef.current; const resolver = epubLocatorResolverRef.current;
const out: TTSSegmentInput[] = []; const out: TTSSegmentInput[] = [];
for (const segment of segments) { const sourceIndices: number[] = [];
for (let idx = 0; idx < segments.length; idx += 1) {
const segment = segments[idx];
const locator = segment.locator; const locator = segment.locator;
if (!locator || locator.readerType !== 'epub') { if (!locator || locator.readerType !== 'epub') {
out.push(segment); out.push(segment);
sourceIndices.push(idx);
continue; continue;
} }
if (!resolver) { if (!resolver) {
@ -461,12 +472,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
? { text: resolved.text } ? { text: resolved.text }
: {}), : {}),
}); });
sourceIndices.push(idx);
} }
} catch (error) { } catch (error) {
console.warn('Failed to resolve EPUB locator; dropping segment', error); console.warn('Failed to resolve EPUB locator; dropping segment', error);
} }
} }
return out; return {
segments: out,
sourceIndices,
};
}, [documentId, ttsSegmentMaxBlockLength]); }, [documentId, ttsSegmentMaxBlockLength]);
/** /**
@ -1376,9 +1391,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
wordHighlightFeatureEnabled && wordHighlightFeatureEnabled &&
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
const locator = locatorOverride || (isEPUB const locator = locatorOverride || locatorForLocation(
? { location: String(currDocPage), readerType: currentReaderType } isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); currentReaderType,
);
const audioCacheKey = buildScopedSegmentCacheKey( const audioCacheKey = buildScopedSegmentCacheKey(
locator, locator,
sentenceIndex, sentenceIndex,
@ -1432,7 +1448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
try { try {
onTTSStart(); onTTSStart();
const persistSegments = await resolveSegmentsForPersist([ const persistResult = await resolveSegmentsForPersist([
{ {
segmentIndex: sentenceIndex, segmentIndex: sentenceIndex,
...(segmentKey ? { segmentKey } : {}), ...(segmentKey ? { segmentKey } : {}),
@ -1440,6 +1456,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
locator, locator,
}, },
]); ]);
const persistSegments = persistResult.segments;
if (persistSegments.length === 0) { if (persistSegments.length === 0) {
if (!preload) setIsPlaying(false); if (!preload) setIsPlaying(false);
return undefined; return undefined;
@ -1566,9 +1583,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
segmentKey?: string | null, segmentKey?: string | null,
): Promise<TTSSegmentPlaybackSource | null> => { ): Promise<TTSSegmentPlaybackSource | null> => {
if (!audioContext) throw new Error('Audio context not initialized'); if (!audioContext) throw new Error('Audio context not initialized');
const locator = locatorOverride || (isEPUB const locator = locatorOverride || locatorForLocation(
? { location: String(currDocPage), readerType: currentReaderType } isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); currentReaderType,
);
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey);
// Check if there's a pending preload request for this sentence // Check if there's a pending preload request for this sentence
@ -1988,9 +2006,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
completedEpubBoundarySegmentRef.current = null; completedEpubBoundarySegmentRef.current = null;
} }
const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator
?? (isEPUB ?? locatorForLocation(
? { location: String(currDocPage), readerType: currentReaderType } isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); currentReaderType,
);
const alignmentKey = buildScopedSegmentCacheKey( const alignmentKey = buildScopedSegmentCacheKey(
activeLocator, activeLocator,
currentIndex, currentIndex,
@ -2284,7 +2303,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadPromise = (async (): Promise<void> => { const preloadPromise = (async (): Promise<void> => {
onTTSStart(); onTTSStart();
started = true; started = true;
const persistPayload = await resolveSegmentsForPersist(payload); const persistResult = await resolveSegmentsForPersist(payload);
const persistPayload = persistResult.segments;
if (persistPayload.length === 0) return; if (persistPayload.length === 0) return;
const ensured = await withRetry( const ensured = await withRetry(
async () => ensureTtsSegments({ async () => ensureTtsSegments({
@ -2303,18 +2323,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (generationAtStart !== epubPreloadGenerationRef.current) return; if (generationAtStart !== epubPreloadGenerationRef.current) return;
const segmentLookup = new Map<string, TTSSegmentManifestItem>(); ensured.segments.forEach((segment, persistIndex) => {
for (const segment of ensured.segments) { if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return;
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) continue; const sourceIndex = persistResult.sourceIndices[persistIndex];
if (!segment.locator) continue; if (sourceIndex === undefined) return;
segmentLookup.set(segment.segmentKey || `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, segment); const candidate = uniqueCandidates[sourceIndex];
} if (!candidate) return;
for (const candidate of uniqueCandidates) {
const segment = segmentLookup.get(candidate.segmentKey);
if (!segment) continue;
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
} });
})(); })();
for (const candidate of uniqueCandidates) { for (const candidate of uniqueCandidates) {
@ -2373,9 +2389,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
cacheKey: string; cacheKey: string;
}> = []; }> = [];
const currentLocator: TTSSegmentLocator = isEPUB const currentLocator: TTSSegmentLocator = locatorForLocation(
? { location: String(currDocPage), readerType: currentReaderType } isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType }; currentReaderType,
);
for (let offset = 1; offset <= sentenceLookahead; offset += 1) { for (let offset = 1; offset <= sentenceLookahead; offset += 1) {
const sentenceIndex = currentIndex + offset; const sentenceIndex = currentIndex + offset;
@ -2407,9 +2424,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
: null; : null;
if (location === null) continue; if (location === null) continue;
const locator: TTSSegmentLocator = typeof location === 'string' const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType);
? { location, readerType: currentReaderType }
: { page: Number(location || 1), readerType: currentReaderType };
for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) { for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) {
const segment = planned[index]; const segment = planned[index];
const sentence = segment.text; const sentence = segment.text;
@ -2471,7 +2486,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const preloadPromise = (async (): Promise<void> => { const preloadPromise = (async (): Promise<void> => {
try { try {
onTTSStart(); onTTSStart();
const persistPayload = await resolveSegmentsForPersist(payload); const persistResult = await resolveSegmentsForPersist(payload);
const persistPayload = persistResult.segments;
if (persistPayload.length === 0) return; if (persistPayload.length === 0) return;
const ensured = await withRetry( const ensured = await withRetry(
async () => ensureTtsSegments({ async () => ensureTtsSegments({
@ -2488,10 +2504,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
retryOptions, retryOptions,
); );
ensured.segments.forEach((segment) => { ensured.segments.forEach((segment, persistIndex) => {
const candidate = candidateLookup.get( const sourceIndex = persistResult.sourceIndices[persistIndex];
candidateLookupKey(segment.segmentIndex, segment.segmentKey), if (sourceIndex === undefined) return;
); const candidate = uniqueCandidates[sourceIndex]
?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey));
if (!candidate) return; if (!candidate) return;
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
}); });

View file

@ -13,4 +13,5 @@ export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSc
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
export const ttsSegments = usePostgres ? postgresSchema.ttsSegments : sqliteSchema.ttsSegments; export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries;
export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants;

View file

@ -109,19 +109,61 @@ export const documentPreviews = pgTable('document_previews', {
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
]); ]);
export const ttsSegments = pgTable('tts_segments', { export const ttsSegmentEntries = pgTable('tts_segment_entries', {
segmentId: text('segment_id').notNull(), segmentEntryId: text('segment_entry_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(), documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), readerType: text('reader_type').notNull(),
documentVersion: bigint('document_version', { mode: 'number' }).notNull(), documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
segmentIndex: integer('segment_index').notNull(), segmentIndex: integer('segment_index').notNull(),
segmentKey: text('segment_key'), segmentKey: text('segment_key'),
locatorJson: text('locator_json'), locatorReaderRank: integer('locator_reader_rank').notNull(),
settingsHash: text('settings_hash').notNull(), locatorReaderType: text('locator_reader_type').notNull(),
settingsJson: jsonb('settings_json').notNull(), locatorPage: integer('locator_page').notNull(),
locatorSpineIndex: integer('locator_spine_index').notNull(),
locatorSpineHref: text('locator_spine_href').notNull(),
locatorCharOffset: integer('locator_char_offset').notNull(),
locatorLocation: text('locator_location').notNull(),
locatorIdentityKey: text('locator_identity_key').notNull(),
textHash: text('text_hash').notNull(), textHash: text('text_hash').notNull(),
textLength: integer('text_length').notNull().default(0), textLength: integer('text_length').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentEntryId, table.userId] }),
index('idx_tts_segment_entries_manifest_sort').on(
table.userId,
table.documentId,
table.documentVersion,
table.locatorReaderRank,
table.locatorSpineIndex,
table.locatorCharOffset,
table.locatorSpineHref,
table.locatorPage,
table.locatorLocation,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_manifest_group').on(
table.userId,
table.documentId,
table.documentVersion,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_scope').on(
table.userId,
table.documentId,
table.documentVersion,
),
]);
export const ttsSegmentVariants = pgTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
segmentEntryId: text('segment_entry_id').notNull(),
settingsHash: text('settings_hash').notNull(),
settingsJson: jsonb('settings_json').notNull(),
audioKey: text('audio_key'), audioKey: text('audio_key'),
audioFormat: text('audio_format').notNull().default('mp3'), audioFormat: text('audio_format').notNull().default('mp3'),
durationMs: integer('duration_ms'), durationMs: integer('duration_ms'),
@ -132,6 +174,11 @@ export const ttsSegments = pgTable('tts_segments', {
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.segmentId, table.userId] }), primaryKey({ columns: [table.segmentId, table.userId] }),
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), foreignKey({
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), columns: [table.segmentEntryId, table.userId],
foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
}).onDelete('cascade'),
index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt),
index('idx_tts_segment_variants_status').on(table.userId, table.status),
index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash),
]); ]);

View file

@ -109,19 +109,61 @@ export const documentPreviews = sqliteTable('document_previews', {
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
]); ]);
export const ttsSegments = sqliteTable('tts_segments', { export const ttsSegmentEntries = sqliteTable('tts_segment_entries', {
segmentId: text('segment_id').notNull(), segmentEntryId: text('segment_entry_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(), documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), readerType: text('reader_type').notNull(),
documentVersion: integer('document_version').notNull(), documentVersion: integer('document_version').notNull(),
segmentIndex: integer('segment_index').notNull(), segmentIndex: integer('segment_index').notNull(),
segmentKey: text('segment_key'), segmentKey: text('segment_key'),
locatorJson: text('locator_json'), locatorReaderRank: integer('locator_reader_rank').notNull(),
settingsHash: text('settings_hash').notNull(), locatorReaderType: text('locator_reader_type').notNull(),
settingsJson: text('settings_json').notNull(), locatorPage: integer('locator_page').notNull(),
locatorSpineIndex: integer('locator_spine_index').notNull(),
locatorSpineHref: text('locator_spine_href').notNull(),
locatorCharOffset: integer('locator_char_offset').notNull(),
locatorLocation: text('locator_location').notNull(),
locatorIdentityKey: text('locator_identity_key').notNull(),
textHash: text('text_hash').notNull(), textHash: text('text_hash').notNull(),
textLength: integer('text_length').notNull().default(0), textLength: integer('text_length').notNull().default(0),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentEntryId, table.userId] }),
index('idx_tts_segment_entries_manifest_sort').on(
table.userId,
table.documentId,
table.documentVersion,
table.locatorReaderRank,
table.locatorSpineIndex,
table.locatorCharOffset,
table.locatorSpineHref,
table.locatorPage,
table.locatorLocation,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_manifest_group').on(
table.userId,
table.documentId,
table.documentVersion,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_scope').on(
table.userId,
table.documentId,
table.documentVersion,
),
]);
export const ttsSegmentVariants = sqliteTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
segmentEntryId: text('segment_entry_id').notNull(),
settingsHash: text('settings_hash').notNull(),
settingsJson: text('settings_json').notNull(),
audioKey: text('audio_key'), audioKey: text('audio_key'),
audioFormat: text('audio_format').notNull().default('mp3'), audioFormat: text('audio_format').notNull().default('mp3'),
durationMs: integer('duration_ms'), durationMs: integer('duration_ms'),
@ -132,6 +174,11 @@ export const ttsSegments = sqliteTable('tts_segments', {
updatedAt: integer('updated_at').default(SQLITE_NOW_MS), updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [ }, (table) => [
primaryKey({ columns: [table.segmentId, table.userId] }), primaryKey({ columns: [table.segmentId, table.userId] }),
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), foreignKey({
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), columns: [table.segmentEntryId, table.userId],
foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
}).onDelete('cascade'),
index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt),
index('idx_tts_segment_variants_status').on(table.userId, table.status),
index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash),
]); ]);

View file

@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegments } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
export type ResolvedSegmentAudio = { export type ResolvedSegmentAudio = {
@ -41,15 +41,19 @@ export async function resolveCompletedSegmentAudio(
const rows = (await db const rows = (await db
.select({ .select({
audioKey: ttsSegments.audioKey, audioKey: ttsSegmentVariants.audioKey,
status: ttsSegments.status, status: ttsSegmentVariants.status,
}) })
.from(ttsSegments) .from(ttsSegmentVariants)
.innerJoin(ttsSegmentEntries, and(
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
))
.where(and( .where(and(
eq(ttsSegments.userId, scope.storageUserId), eq(ttsSegmentVariants.userId, scope.storageUserId),
eq(ttsSegments.documentId, documentId), eq(ttsSegmentEntries.documentId, documentId),
eq(ttsSegments.documentVersion, scope.documentVersion), eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
eq(ttsSegments.segmentId, segmentId), eq(ttsSegmentVariants.segmentId, segmentId),
))) as Array<{ audioKey: string | null; status: string }>; ))) as Array<{ audioKey: string | null; status: string }>;
const row = rows[0]; const row = rows[0];

View file

@ -10,6 +10,18 @@ export const DEFAULT_PAGE_SIZE = 150;
export const MIN_PAGE_SIZE = 25; export const MIN_PAGE_SIZE = 25;
export const MAX_PAGE_SIZE = 500; export const MAX_PAGE_SIZE = 500;
export type TTSSegmentManifestCursor = {
locatorReaderRank: number;
locatorSpineIndex: number;
locatorCharOffset: number;
locatorSpineHref: string;
locatorPage: number;
locatorLocation: string;
segmentIndex: number;
locatorIdentityKey: string;
segmentEntryId: string;
};
function statusRank(status: TTSSegmentVariant['status']): number { function statusRank(status: TTSSegmentVariant['status']): number {
if (status === 'completed') return 3; if (status === 'completed') return 3;
if (status === 'pending') return 2; if (status === 'pending') return 2;
@ -45,24 +57,49 @@ export function compareManifestSegments(
return a.groupKey.localeCompare(b.groupKey); return a.groupKey.localeCompare(b.groupKey);
} }
export function decodeManifestCursor(cursor: string | null): string | null { export function decodeManifestCursor(cursor: string | null): TTSSegmentManifestCursor | null {
if (!cursor) return null; if (!cursor) return null;
try { try {
const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
if (!decoded) return null; if (!decoded) return null;
// Reject malformed input that Node's base64url decoder may parse into gibberish. let parsed: unknown = null;
const normalizedInput = cursor.replace(/=+$/, ''); try {
if (encodeManifestCursor(decoded) !== normalizedInput) { parsed = JSON.parse(decoded);
} catch {
return null; return null;
} }
return decoded; if (!parsed || typeof parsed !== 'object') return null;
const rec = parsed as Record<string, unknown>;
const next: TTSSegmentManifestCursor = {
locatorReaderRank: Number(rec.locatorReaderRank),
locatorSpineIndex: Number(rec.locatorSpineIndex),
locatorCharOffset: Number(rec.locatorCharOffset),
locatorSpineHref: typeof rec.locatorSpineHref === 'string' ? rec.locatorSpineHref : '',
locatorPage: Number(rec.locatorPage),
locatorLocation: typeof rec.locatorLocation === 'string' ? rec.locatorLocation : '',
segmentIndex: Number(rec.segmentIndex),
locatorIdentityKey: typeof rec.locatorIdentityKey === 'string' ? rec.locatorIdentityKey : '',
segmentEntryId: typeof rec.segmentEntryId === 'string' ? rec.segmentEntryId : '',
};
if (!Number.isFinite(next.locatorReaderRank)) return null;
if (!Number.isFinite(next.locatorSpineIndex)) return null;
if (!Number.isFinite(next.locatorCharOffset)) return null;
if (!Number.isFinite(next.locatorPage)) return null;
if (!Number.isFinite(next.segmentIndex)) return null;
if (!next.locatorIdentityKey) return null;
if (!next.segmentEntryId) return null;
const normalizedInput = cursor.replace(/=+$/, '');
if (encodeManifestCursor(next) !== normalizedInput) {
return null;
}
return next;
} catch { } catch {
return null; return null;
} }
} }
export function encodeManifestCursor(value: string): string { export function encodeManifestCursor(value: TTSSegmentManifestCursor): string {
return Buffer.from(value, 'utf8').toString('base64url'); return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
} }
export function parseManifestPageSize(value: string | null): number { export function parseManifestPageSize(value: string | null): number {

View file

@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters'; import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
import type { import type {
TTSSegmentLocator, TTSSegmentLocator,
@ -54,6 +55,17 @@ export function normalizeSegmentText(text: string): string {
return preprocessSentenceForAudio(text || '').trim(); return preprocessSentenceForAudio(text || '').trim();
} }
export type TTSSegmentLocatorProjection = {
locatorReaderRank: number;
locatorReaderType: string;
locatorPage: number;
locatorSpineIndex: number;
locatorSpineHref: string;
locatorCharOffset: number;
locatorLocation: string;
locatorIdentityKey: string;
};
/** /**
* Validate and shape a locator for persistence. EPUB locators MUST carry the * Validate and shape a locator for persistence. EPUB locators MUST carry the
* stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) the * stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) the
@ -104,27 +116,51 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg
return null; return null;
} }
export function projectSegmentLocator(locator: TTSSegmentLocator): TTSSegmentLocatorProjection {
if (locator.readerType === 'epub') {
return {
locatorReaderRank: 0,
locatorReaderType: 'epub',
locatorPage: -1,
locatorSpineIndex: typeof locator.spineIndex === 'number' ? locator.spineIndex : -1,
locatorSpineHref: typeof locator.spineHref === 'string' ? locator.spineHref : '',
locatorCharOffset: typeof locator.charOffset === 'number' ? locator.charOffset : -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'pdf') {
return {
locatorReaderRank: 1,
locatorReaderType: 'pdf',
locatorPage: typeof locator.page === 'number' ? Math.floor(locator.page) : -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
if (locator.readerType === 'html') {
return {
locatorReaderRank: 2,
locatorReaderType: 'html',
locatorPage: -1,
locatorSpineIndex: -1,
locatorSpineHref: '',
locatorCharOffset: -1,
locatorLocation: typeof locator.location === 'string' ? locator.location : '',
locatorIdentityKey: locatorIdentityKey(locator),
};
}
throw new Error(`Unsupported segment locator readerType for projection: ${String(locator.readerType)}`);
}
export function locatorFingerprint(locator: TTSSegmentLocator | null): string { export function locatorFingerprint(locator: TTSSegmentLocator | null): string {
if (!locator) return ''; if (!locator) return '';
return createHash('sha256').update(stableStringify(locator)).digest('hex'); return createHash('sha256').update(stableStringify(locator)).digest('hex');
} }
export function canonicalLocatorJson(locator: TTSSegmentLocator | null | undefined): string | null {
if (!locator) return null;
return stableStringify(locator);
}
export function canonicalizeLocatorJsonString(json: string | null | undefined): string | null {
if (!json) return null;
try {
const parsed = JSON.parse(json);
if (parsed === null || typeof parsed !== 'object') return null;
return stableStringify(parsed);
} catch {
return null;
}
}
export function buildTtsSegmentId(input: { export function buildTtsSegmentId(input: {
documentId: string; documentId: string;
documentVersion: number; documentVersion: number;
@ -146,6 +182,25 @@ export function buildTtsSegmentId(input: {
return createHash('sha256').update(canonical).digest('hex'); return createHash('sha256').update(canonical).digest('hex');
} }
export function buildTtsSegmentEntryId(input: {
documentId: string;
documentVersion: number;
segmentIndex: number;
segmentKey?: string | null;
locatorIdentityKey: string;
textHash: string;
}): string {
const canonical = stableStringify({
d: input.documentId,
v: input.documentVersion,
i: input.segmentIndex,
k: input.segmentKey ? input.segmentKey : null,
l: input.locatorIdentityKey,
t: input.textHash,
});
return createHash('sha256').update(canonical).digest('hex');
}
export function buildTtsSegmentTextHash(text: string, secret: string): string { export function buildTtsSegmentTextHash(text: string, secret: string): string {
return createHmac('sha256', secret).update(text).digest('hex'); return createHmac('sha256', secret).update(text).digest('hex');
} }
@ -160,7 +215,7 @@ export function buildTtsSegmentAudioKey(input: {
segmentId: string; segmentId: string;
}): string { }): string {
const nsSegment = input.namespace ? `ns/${input.namespace}/` : ''; const nsSegment = input.namespace ? `ns/${input.namespace}/` : '';
return `${input.storagePrefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`; return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`;
} }
export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> { export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> {

View file

@ -92,8 +92,10 @@ export async function deleteUserStorageData(
try { try {
const cfg = getS3Config(); const cfg = getS3Config();
const nsSegment = namespace ? `ns/${namespace}/` : ''; const nsSegment = namespace ? `ns/${namespace}/` : '';
const ttsPrefix = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`; const ttsPrefixV1 = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`;
segmentsDeleted = await deleteTtsSegmentPrefix(ttsPrefix); const ttsPrefixV2 = `${cfg.prefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(userId)}/`;
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1);
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
} catch (error) { } catch (error) {
console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error); console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error);
} }

View file

@ -76,7 +76,8 @@ export default async function globalTeardown(): Promise<void> {
const config = getS3Config(); const config = getS3Config();
const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`; const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`;
const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`; const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`;
const ttsSegmentsNsRootPrefix = `${config.prefix}/tts_segments_v1/ns/`; const ttsSegmentsNsRootPrefixV1 = `${config.prefix}/tts_segments_v1/ns/`;
const ttsSegmentsNsRootPrefixV2 = `${config.prefix}/tts_segments_v2/ns/`;
// Remove SQL audiobook rows for namespaced objects (covers auth claim flows too). // Remove SQL audiobook rows for namespaced objects (covers auth claim flows too).
const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix); const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix);
@ -105,5 +106,6 @@ export default async function globalTeardown(): Promise<void> {
await deleteDocumentPrefix(docsNsRootPrefix); await deleteDocumentPrefix(docsNsRootPrefix);
await deleteAudiobookPrefix(audiobooksNsRootPrefix); await deleteAudiobookPrefix(audiobooksNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefix); await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2);
} }

View file

@ -157,10 +157,22 @@ test.describe('tts segments manifest helpers', () => {
}); });
test('encodes and decodes cursors', () => { test('encodes and decodes cursors', () => {
const raw = '3|p:2|l:epubcfi(/6/2)|r:epub'; const raw = {
locatorReaderRank: 0,
locatorSpineIndex: 2,
locatorCharOffset: 128,
locatorSpineHref: 'OEBPS/ch02.xhtml',
locatorPage: -1,
locatorLocation: '',
segmentIndex: 4,
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
segmentEntryId: 'entry-4',
};
const encoded = encodeManifestCursor(raw); const encoded = encodeManifestCursor(raw);
expect(decodeManifestCursor(encoded)).toBe(raw); expect(decodeManifestCursor(encoded)).toEqual(raw);
expect(decodeManifestCursor('not-base64')).toBeNull(); expect(decodeManifestCursor('not-base64')).toBeNull();
const malformed = Buffer.from(JSON.stringify({ bad: true }), 'utf8').toString('base64url');
expect(decodeManifestCursor(malformed)).toBeNull();
}); });
test('clamps page size bounds', () => { test('clamps page size bounds', () => {

View file

@ -1,12 +1,14 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { import {
buildProportionalAlignment, buildProportionalAlignment,
buildTtsSegmentEntryId,
buildTtsSegmentId, buildTtsSegmentId,
buildTtsSegmentSettingsHash, buildTtsSegmentSettingsHash,
buildTtsSegmentTextHash, buildTtsSegmentTextHash,
locatorFingerprint, locatorFingerprint,
normalizeLocator, normalizeLocator,
normalizeSegmentText, normalizeSegmentText,
projectSegmentLocator,
} from '../../src/lib/server/tts/segments'; } from '../../src/lib/server/tts/segments';
test.describe('tts segment helpers', () => { test.describe('tts segment helpers', () => {
@ -57,6 +59,35 @@ test.describe('tts segment helpers', () => {
expect(id1).not.toBe(id3); expect(id1).not.toBe(id3);
}); });
test('builds deterministic segment entry id independent of settings', () => {
const entry1 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-a',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
const entry2 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-a',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
const entry3 = buildTtsSegmentEntryId({
documentId: 'doc',
documentVersion: 1,
segmentIndex: 2,
segmentKey: 'doc:v1:segment-b',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
textHash: 'abc123',
});
expect(entry1).toBe(entry2);
expect(entry1).not.toBe(entry3);
});
test('canonical segment key makes id independent of locator and index', () => { test('canonical segment key makes id independent of locator and index', () => {
const id1 = buildTtsSegmentId({ const id1 = buildTtsSegmentId({
documentId: 'doc', documentId: 'doc',
@ -97,8 +128,15 @@ test.describe('tts segment helpers', () => {
test('normalizes PDF locators and creates fingerprints', () => { test('normalizes PDF locators and creates fingerprints', () => {
const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 }); const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 });
if (!locator) throw new Error('expected normalized pdf locator');
expect(locator).toEqual({ readerType: 'pdf', page: 2 }); expect(locator).toEqual({ readerType: 'pdf', page: 2 });
expect(locatorFingerprint(locator)).toHaveLength(64); expect(locatorFingerprint(locator)).toHaveLength(64);
expect(projectSegmentLocator(locator)).toMatchObject({
locatorReaderRank: 1,
locatorReaderType: 'pdf',
locatorPage: 2,
locatorIdentityKey: 'pdf:2',
});
}); });
test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => { test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => {
@ -110,6 +148,7 @@ test.describe('tts segment helpers', () => {
charOffset: 128.4, charOffset: 128.4,
cfi: ' epubcfi(/6/4!/4:0) ', cfi: ' epubcfi(/6/4!/4:0) ',
}); });
if (!stable) throw new Error('expected normalized epub locator');
expect(stable).toEqual({ expect(stable).toEqual({
readerType: 'epub', readerType: 'epub',
spineHref: 'OEBPS/ch02.xhtml', spineHref: 'OEBPS/ch02.xhtml',
@ -118,6 +157,14 @@ test.describe('tts segment helpers', () => {
cfi: 'epubcfi(/6/4!/4:0)', cfi: 'epubcfi(/6/4!/4:0)',
}); });
expect(locatorFingerprint(stable)).toHaveLength(64); expect(locatorFingerprint(stable)).toHaveLength(64);
expect(projectSegmentLocator(stable)).toMatchObject({
locatorReaderRank: 0,
locatorReaderType: 'epub',
locatorSpineIndex: 2,
locatorCharOffset: 128,
locatorSpineHref: 'OEBPS/ch02.xhtml',
locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128',
});
// Legacy/draft EPUB shape (CFI in `location` but no spine coords) is // Legacy/draft EPUB shape (CFI in `location` but no spine coords) is
// rejected so we never persist viewport-dependent locators. // rejected so we never persist viewport-dependent locators.
@ -125,6 +172,15 @@ test.describe('tts segment helpers', () => {
expect(legacy).toBeNull(); expect(legacy).toBeNull();
}); });
test('projects html locators into stable manifest sort fields', () => {
expect(projectSegmentLocator({ readerType: 'html', location: '#intro' })).toMatchObject({
locatorReaderRank: 2,
locatorReaderType: 'html',
locatorLocation: '#intro',
locatorIdentityKey: 'html:#intro',
});
});
test('builds proportional alignment preserving order', () => { test('builds proportional alignment preserving order', () => {
const alignment = buildProportionalAlignment({ const alignment = buildProportionalAlignment({
sentence: normalizeSegmentText('Hello world again'), sentence: normalizeSegmentText('Hello world again'),