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:
parent
f9adba791d
commit
8488ad37e1
21 changed files with 3853 additions and 298 deletions
50
drizzle/postgres/0003_tts_segments_v2_split.sql
Normal file
50
drizzle/postgres/0003_tts_segments_v2_split.sql
Normal 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");
|
||||
1582
drizzle/postgres/meta/0003_snapshot.json
Normal file
1582
drizzle/postgres/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1778502597852,
|
||||
"tag": "0002_add_segment_key_to_tts_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1778627816569,
|
||||
"tag": "0003_tts_segments_v2_split",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
50
drizzle/sqlite/0003_tts_segments_v2_split.sql
Normal file
50
drizzle/sqlite/0003_tts_segments_v2_split.sql
Normal 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`;
|
||||
1435
drizzle/sqlite/meta/0003_snapshot.json
Normal file
1435
drizzle/sqlite/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,13 @@
|
|||
"when": 1778502597591,
|
||||
"tag": "0002_add_segment_key_to_tts_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1778627810476,
|
||||
"tag": "0003_tts_segments_v2_split",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { ttsSegments } from '@/db/schema';
|
||||
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
||||
import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
|
||||
|
|
@ -27,22 +27,26 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const rows = (await db
|
||||
.select({
|
||||
segmentId: ttsSegments.segmentId,
|
||||
audioKey: ttsSegments.audioKey,
|
||||
segmentId: ttsSegmentVariants.segmentId,
|
||||
audioKey: ttsSegmentVariants.audioKey,
|
||||
})
|
||||
.from(ttsSegments)
|
||||
.from(ttsSegmentVariants)
|
||||
.innerJoin(ttsSegmentEntries, and(
|
||||
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
|
||||
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
|
||||
))
|
||||
.where(and(
|
||||
eq(ttsSegments.userId, scope.storageUserId),
|
||||
eq(ttsSegments.documentId, parsed.documentId),
|
||||
eq(ttsSegments.documentVersion, scope.documentVersion),
|
||||
eq(ttsSegmentEntries.userId, scope.storageUserId),
|
||||
eq(ttsSegmentEntries.documentId, parsed.documentId),
|
||||
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
|
||||
))) as Array<{ segmentId: string; audioKey: string | null }>;
|
||||
|
||||
await db
|
||||
.delete(ttsSegments)
|
||||
.delete(ttsSegmentEntries)
|
||||
.where(and(
|
||||
eq(ttsSegments.userId, scope.storageUserId),
|
||||
eq(ttsSegments.documentId, parsed.documentId),
|
||||
eq(ttsSegments.documentVersion, scope.documentVersion),
|
||||
eq(ttsSegmentEntries.userId, scope.storageUserId),
|
||||
eq(ttsSegmentEntries.documentId, parsed.documentId),
|
||||
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
|
||||
));
|
||||
|
||||
const audioKeys = rows
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
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 { ttsSegments } from '@/db/schema';
|
||||
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
||||
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
|
||||
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
||||
import {
|
||||
deleteTtsSegmentAudioObjects,
|
||||
getTtsSegmentAudioObject,
|
||||
putTtsSegmentAudioObject,
|
||||
} from '@/lib/server/tts/segments-blobstore';
|
||||
import {
|
||||
buildTtsSegmentAudioKey,
|
||||
buildTtsSegmentEntryId,
|
||||
buildTtsSegmentId,
|
||||
buildTtsSegmentSettingsHash,
|
||||
buildTtsSegmentSettingsJson,
|
||||
buildTtsSegmentTextHash,
|
||||
canonicalLocatorJson,
|
||||
canonicalizeLocatorJsonString,
|
||||
locatorFingerprint,
|
||||
normalizeLocator,
|
||||
normalizeSegmentText,
|
||||
projectSegmentLocator,
|
||||
probeAudioDurationMsFromBuffer,
|
||||
} from '@/lib/server/tts/segments';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
|
|
@ -140,67 +139,24 @@ function isAbortLikeError(error: unknown): boolean {
|
|||
return /abort/i.test(message);
|
||||
}
|
||||
|
||||
async function cleanupStaleCanonicalVariants(input: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
documentVersion: number;
|
||||
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)
|
||||
async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Promise<void> {
|
||||
const stillReferenced = await db
|
||||
.select({ segmentId: ttsSegmentVariants.segmentId })
|
||||
.from(ttsSegmentVariants)
|
||||
.where(and(
|
||||
eq(ttsSegments.userId, input.userId),
|
||||
eq(ttsSegments.documentId, input.documentId),
|
||||
eq(ttsSegments.documentVersion, input.documentVersion),
|
||||
eq(ttsSegments.segmentIndex, input.segmentIndex),
|
||||
ne(ttsSegments.segmentId, input.activeSegmentId),
|
||||
))) as Array<{
|
||||
segmentId: string;
|
||||
settingsHash: string;
|
||||
audioKey: string | null;
|
||||
locatorJson: string | null;
|
||||
}>;
|
||||
eq(ttsSegmentVariants.userId, userId),
|
||||
eq(ttsSegmentVariants.segmentEntryId, segmentEntryId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson);
|
||||
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;
|
||||
if (stillReferenced.length > 0) return;
|
||||
|
||||
await db
|
||||
.delete(ttsSegments)
|
||||
.delete(ttsSegmentEntries)
|
||||
.where(and(
|
||||
eq(ttsSegments.userId, input.userId),
|
||||
inArray(ttsSegments.segmentId, staleIds),
|
||||
eq(ttsSegmentEntries.userId, userId),
|
||||
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) {
|
||||
|
|
@ -223,11 +179,16 @@ export async function POST(request: NextRequest) {
|
|||
const storagePrefix = getS3Config().prefix;
|
||||
const secret = textHmacSecret();
|
||||
|
||||
let invalidLocatorIndex = -1;
|
||||
const normalized = parsed.segments
|
||||
.map((segment) => {
|
||||
.map((segment, index) => {
|
||||
const text = normalizeSegmentText(segment.text);
|
||||
if (!text) return null;
|
||||
const locator = normalizeLocator(segment.locator);
|
||||
if (!locator) {
|
||||
invalidLocatorIndex = index;
|
||||
return null;
|
||||
}
|
||||
const locatorHash = locatorFingerprint(locator);
|
||||
const segmentId = buildTtsSegmentId({
|
||||
documentId: parsed.documentId,
|
||||
|
|
@ -249,6 +210,13 @@ export async function POST(request: NextRequest) {
|
|||
})
|
||||
.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) {
|
||||
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 rows = (await db
|
||||
.select()
|
||||
.from(ttsSegments)
|
||||
.where(and(eq(ttsSegments.userId, scope.storageUserId), inArray(ttsSegments.segmentId, ids)))) as Array<{
|
||||
.from(ttsSegmentVariants)
|
||||
.where(and(
|
||||
eq(ttsSegmentVariants.userId, scope.storageUserId),
|
||||
inArray(ttsSegmentVariants.segmentId, ids),
|
||||
))) as Array<{
|
||||
segmentId: string;
|
||||
userId: string;
|
||||
documentId: string;
|
||||
readerType: string;
|
||||
documentVersion: number;
|
||||
segmentIndex: number;
|
||||
locatorJson: string | null;
|
||||
segmentEntryId: string;
|
||||
settingsHash: string;
|
||||
settingsJson: unknown;
|
||||
textHash: string;
|
||||
textLength: number;
|
||||
audioKey: string | null;
|
||||
audioFormat: string;
|
||||
durationMs: number | null;
|
||||
|
|
@ -283,25 +248,74 @@ export async function POST(request: NextRequest) {
|
|||
const manifest: TTSSegmentManifestItem[] = [];
|
||||
|
||||
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 cleanupStaleCanonicalVariants({
|
||||
await db
|
||||
.insert(ttsSegmentEntries)
|
||||
.values({
|
||||
segmentEntryId,
|
||||
userId: scope.storageUserId,
|
||||
documentId: parsed.documentId,
|
||||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
activeLocatorJson: existing.locatorJson,
|
||||
activeSegmentId: segment.segmentId,
|
||||
activeSettingsHash: settingsHash,
|
||||
segmentKey: segmentKeyForRow,
|
||||
...locatorProjection,
|
||||
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
|
||||
? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment'])
|
||||
: null;
|
||||
const locator = existing.locatorJson
|
||||
? (JSON.parse(existing.locatorJson) as TTSSegmentManifestItem['locator'])
|
||||
: null;
|
||||
const locator = segment.locator;
|
||||
|
||||
// Self-heal transient Whisper failures: if audio exists but alignment was
|
||||
// previously unavailable, retry alignment using the current segment text.
|
||||
|
|
@ -319,12 +333,15 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
if (alignment) {
|
||||
await db
|
||||
.update(ttsSegments)
|
||||
.update(ttsSegmentVariants)
|
||||
.set({
|
||||
alignmentJson: JSON.stringify(alignment),
|
||||
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) {
|
||||
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({
|
||||
segmentId: segment.segmentId,
|
||||
segmentIndex: existing.segmentIndex,
|
||||
segmentKey: segment.original.segmentKey ?? null,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segmentKeyForRow,
|
||||
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
|
||||
durationMs: existing.durationMs ?? 0,
|
||||
alignment,
|
||||
|
|
@ -358,25 +375,14 @@ export async function POST(request: NextRequest) {
|
|||
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
|
||||
.insert(ttsSegments)
|
||||
.insert(ttsSegmentVariants)
|
||||
.values({
|
||||
segmentId: segment.segmentId,
|
||||
userId: scope.storageUserId,
|
||||
documentId: parsed.documentId,
|
||||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segmentKeyForRow,
|
||||
locatorJson: segmentLocatorJson,
|
||||
segmentEntryId,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
textHash: segment.textHash,
|
||||
textLength: segment.text.length,
|
||||
audioKey,
|
||||
audioFormat: 'mp3',
|
||||
status: 'pending',
|
||||
|
|
@ -384,18 +390,11 @@ export async function POST(request: NextRequest) {
|
|||
updatedAt: nowMs,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [ttsSegments.segmentId, ttsSegments.userId],
|
||||
target: [ttsSegmentVariants.segmentId, ttsSegmentVariants.userId],
|
||||
set: {
|
||||
documentId: parsed.documentId,
|
||||
readerType: scope.readerType,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segmentKeyForRow,
|
||||
locatorJson: segmentLocatorJson,
|
||||
segmentEntryId,
|
||||
settingsHash,
|
||||
settingsJson,
|
||||
textHash: segment.textHash,
|
||||
textLength: segment.text.length,
|
||||
audioKey,
|
||||
audioFormat: 'mp3',
|
||||
status: 'pending',
|
||||
|
|
@ -404,6 +403,10 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
if (movedFromEntryId) {
|
||||
await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
|
||||
const charCount = segment.text.length;
|
||||
|
|
@ -493,7 +496,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
await db
|
||||
.update(ttsSegments)
|
||||
.update(ttsSegmentVariants)
|
||||
.set({
|
||||
durationMs,
|
||||
alignmentJson: alignment ? JSON.stringify(alignment) : null,
|
||||
|
|
@ -501,22 +504,15 @@ export async function POST(request: NextRequest) {
|
|||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId)));
|
||||
|
||||
await cleanupStaleCanonicalVariants({
|
||||
userId: scope.storageUserId,
|
||||
documentId: parsed.documentId,
|
||||
documentVersion: scope.documentVersion,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
activeLocatorJson: canonicalLocatorJson(segment.locator),
|
||||
activeSegmentId: segment.segmentId,
|
||||
activeSettingsHash: settingsHash,
|
||||
});
|
||||
.where(and(
|
||||
eq(ttsSegmentVariants.segmentId, segment.segmentId),
|
||||
eq(ttsSegmentVariants.userId, scope.storageUserId),
|
||||
));
|
||||
|
||||
manifest.push({
|
||||
segmentId: segment.segmentId,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segment.original.segmentKey ?? null,
|
||||
segmentKey: segmentKeyForRow,
|
||||
...buildSegmentAudioUrls(parsed.documentId, segment.segmentId),
|
||||
durationMs,
|
||||
alignment,
|
||||
|
|
@ -527,18 +523,21 @@ export async function POST(request: NextRequest) {
|
|||
const message = error instanceof Error ? error.message : 'Failed to generate segment';
|
||||
const aborted = isAbortLikeError(error);
|
||||
await db
|
||||
.update(ttsSegments)
|
||||
.update(ttsSegmentVariants)
|
||||
.set({
|
||||
status: aborted ? 'pending' : 'error',
|
||||
error: aborted ? null : message,
|
||||
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({
|
||||
segmentId: segment.segmentId,
|
||||
segmentIndex: segment.original.segmentIndex,
|
||||
segmentKey: segment.original.segmentKey ?? null,
|
||||
segmentKey: segmentKeyForRow,
|
||||
audioPresignUrl: null,
|
||||
audioFallbackUrl: null,
|
||||
durationMs: 0,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
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 { ttsSegments } from '@/db/schema';
|
||||
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
import {
|
||||
compareManifestSegments,
|
||||
decodeManifestCursor,
|
||||
dedupeManifestVariants,
|
||||
encodeManifestCursor,
|
||||
locatorIdentityKey,
|
||||
parseManifestPageSize,
|
||||
type TTSSegmentManifestCursor,
|
||||
} from '@/lib/server/tts/segments-manifest';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
|
|
@ -22,6 +21,21 @@ import type {
|
|||
export const runtime = 'nodejs';
|
||||
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 {
|
||||
let raw: unknown = value;
|
||||
if (typeof raw === 'string') {
|
||||
|
|
@ -30,9 +44,6 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | 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;
|
||||
|
|
@ -49,16 +60,22 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | 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 locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
||||
if (row.locatorReaderType === 'epub' && row.locatorSpineIndex >= 0 && row.locatorCharOffset >= 0 && row.locatorSpineHref) {
|
||||
return {
|
||||
readerType: 'epub',
|
||||
spineIndex: row.locatorSpineIndex,
|
||||
spineHref: row.locatorSpineHref,
|
||||
charOffset: row.locatorCharOffset,
|
||||
};
|
||||
}
|
||||
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): {
|
||||
|
|
@ -78,6 +95,86 @@ function isAbortLikeMessage(message: string | null | undefined): boolean {
|
|||
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) {
|
||||
try {
|
||||
const documentIdRaw = request.nextUrl.searchParams.get('documentId');
|
||||
|
|
@ -91,27 +188,92 @@ export async function GET(request: NextRequest) {
|
|||
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),
|
||||
const scopeWhere = and(
|
||||
eq(ttsSegmentEntries.userId, scope.storageUserId),
|
||||
eq(ttsSegmentEntries.documentId, documentId),
|
||||
eq(ttsSegmentEntries.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;
|
||||
userId: string;
|
||||
documentId: string;
|
||||
readerType: string;
|
||||
documentVersion: number;
|
||||
segmentIndex: number;
|
||||
segmentKey: string | null;
|
||||
locatorJson: string | null;
|
||||
segmentEntryId: string;
|
||||
settingsHash: string;
|
||||
settingsJson: unknown;
|
||||
textHash: string;
|
||||
textLength: number;
|
||||
audioKey: string | null;
|
||||
audioFormat: string;
|
||||
durationMs: number | null;
|
||||
|
|
@ -126,26 +288,21 @@ export async function GET(request: NextRequest) {
|
|||
variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>;
|
||||
}>();
|
||||
|
||||
for (const row of rows) {
|
||||
const locator = parseLocator(row.locatorJson);
|
||||
// Use the per-row identity key (not the coarse sidebar group key) so two
|
||||
// persisted rows in the same chapter at different `charOffset`s remain
|
||||
// distinct entries instead of collapsing into one bucket whose locator
|
||||
// only reflects the first row seen.
|
||||
const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`;
|
||||
for (const row of variantRows) {
|
||||
const entryMeta = entryById.get(row.segmentEntryId);
|
||||
if (!entryMeta) continue;
|
||||
const key = row.segmentEntryId;
|
||||
const locator = locatorFromProjection(entryMeta);
|
||||
|
||||
let entry = grouped.get(groupKey);
|
||||
let entry = grouped.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
segmentIndex: row.segmentIndex,
|
||||
segmentKey: row.segmentKey,
|
||||
segmentIndex: entryMeta.segmentIndex,
|
||||
segmentKey: entryMeta.segmentKey,
|
||||
locator,
|
||||
variants: [],
|
||||
};
|
||||
grouped.set(groupKey, entry);
|
||||
} else {
|
||||
if (!entry.locator) entry.locator = locator;
|
||||
if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey;
|
||||
grouped.set(key, entry);
|
||||
}
|
||||
|
||||
let alignmentWordCount = 0;
|
||||
|
|
@ -177,7 +334,7 @@ export async function GET(request: NextRequest) {
|
|||
audioFallbackUrl: audioUrls.audioFallbackUrl,
|
||||
durationMs: row.durationMs,
|
||||
status,
|
||||
textLength: row.textLength,
|
||||
textLength: entryMeta.textLength,
|
||||
alignmentWordCount,
|
||||
audioKey: row.audioKey,
|
||||
updatedAt: row.updatedAt,
|
||||
|
|
@ -185,39 +342,25 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
const segments = Array.from(grouped.entries())
|
||||
.map(([groupKey, segment]) => ({
|
||||
groupKey,
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentKey: segment.segmentKey,
|
||||
locator: segment.locator,
|
||||
variants: dedupeManifestVariants(segment.variants),
|
||||
}))
|
||||
.sort(compareManifestSegments);
|
||||
const segments = pageGroups
|
||||
.map((row) => {
|
||||
const key = row.segmentEntryId;
|
||||
const entry = grouped.get(key);
|
||||
return {
|
||||
segmentIndex: row.segmentIndex,
|
||||
segmentKey: row.segmentKey ?? null,
|
||||
locator: entry?.locator || locatorFromProjection(row),
|
||||
variants: dedupeManifestVariants(entry?.variants || []),
|
||||
};
|
||||
});
|
||||
|
||||
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)
|
||||
const nextCursor = hasMore
|
||||
? encodeManifestCursor(cursorFromGroupRow(pageGroups[pageGroups.length - 1]))
|
||||
: null;
|
||||
|
||||
const response: TTSSegmentsManifestResponse = {
|
||||
documentId,
|
||||
segments: page.map((segment) => ({
|
||||
segmentIndex: segment.segmentIndex,
|
||||
segmentKey: segment.segmentKey ?? null,
|
||||
locator: segment.locator,
|
||||
variants: segment.variants,
|
||||
})),
|
||||
segments,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ type FetchState =
|
|||
| {
|
||||
kind: 'ready';
|
||||
data: TTSSegmentRow[];
|
||||
fetchedAt: number;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
|
|
@ -290,7 +289,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb
|
|||
return {
|
||||
kind: 'ready',
|
||||
data: merged,
|
||||
fetchedAt: Date.now(),
|
||||
nextCursor: data.nextCursor,
|
||||
hasMore: data.hasMore,
|
||||
loadingMore: false,
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@ type EpubLocationPreloadCandidate = {
|
|||
cacheKey: string;
|
||||
};
|
||||
|
||||
type PersistResolution = {
|
||||
segments: TTSSegmentInput[];
|
||||
sourceIndices: number[];
|
||||
};
|
||||
|
||||
type JumpResolutionInput = {
|
||||
isEPUB: boolean;
|
||||
newSentenceCount: number;
|
||||
|
|
@ -313,6 +318,9 @@ const locatorForLocation = (
|
|||
if (typeof location === 'string') {
|
||||
return { location, readerType };
|
||||
}
|
||||
if (readerType === 'html') {
|
||||
return { location: String(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 (
|
||||
segments: TTSSegmentInput[],
|
||||
): Promise<TTSSegmentInput[]> => {
|
||||
): Promise<PersistResolution> => {
|
||||
const resolver = epubLocatorResolverRef.current;
|
||||
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;
|
||||
if (!locator || locator.readerType !== 'epub') {
|
||||
out.push(segment);
|
||||
sourceIndices.push(idx);
|
||||
continue;
|
||||
}
|
||||
if (!resolver) {
|
||||
|
|
@ -461,12 +472,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
? { text: resolved.text }
|
||||
: {}),
|
||||
});
|
||||
sourceIndices.push(idx);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve EPUB locator; dropping segment', error);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return {
|
||||
segments: out,
|
||||
sourceIndices,
|
||||
};
|
||||
}, [documentId, ttsSegmentMaxBlockLength]);
|
||||
|
||||
/**
|
||||
|
|
@ -1376,9 +1391,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
wordHighlightFeatureEnabled &&
|
||||
((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled));
|
||||
const locator = locatorOverride || (isEPUB
|
||||
? { location: String(currDocPage), readerType: currentReaderType }
|
||||
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
|
||||
const locator = locatorOverride || locatorForLocation(
|
||||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||||
currentReaderType,
|
||||
);
|
||||
const audioCacheKey = buildScopedSegmentCacheKey(
|
||||
locator,
|
||||
sentenceIndex,
|
||||
|
|
@ -1432,7 +1448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
try {
|
||||
onTTSStart();
|
||||
const persistSegments = await resolveSegmentsForPersist([
|
||||
const persistResult = await resolveSegmentsForPersist([
|
||||
{
|
||||
segmentIndex: sentenceIndex,
|
||||
...(segmentKey ? { segmentKey } : {}),
|
||||
|
|
@ -1440,6 +1456,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
locator,
|
||||
},
|
||||
]);
|
||||
const persistSegments = persistResult.segments;
|
||||
if (persistSegments.length === 0) {
|
||||
if (!preload) setIsPlaying(false);
|
||||
return undefined;
|
||||
|
|
@ -1566,9 +1583,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
segmentKey?: string | null,
|
||||
): Promise<TTSSegmentPlaybackSource | null> => {
|
||||
if (!audioContext) throw new Error('Audio context not initialized');
|
||||
const locator = locatorOverride || (isEPUB
|
||||
? { location: String(currDocPage), readerType: currentReaderType }
|
||||
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
|
||||
const locator = locatorOverride || locatorForLocation(
|
||||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||||
currentReaderType,
|
||||
);
|
||||
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey);
|
||||
|
||||
// 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;
|
||||
}
|
||||
const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator
|
||||
?? (isEPUB
|
||||
? { location: String(currDocPage), readerType: currentReaderType }
|
||||
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType });
|
||||
?? locatorForLocation(
|
||||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||||
currentReaderType,
|
||||
);
|
||||
const alignmentKey = buildScopedSegmentCacheKey(
|
||||
activeLocator,
|
||||
currentIndex,
|
||||
|
|
@ -2284,7 +2303,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const preloadPromise = (async (): Promise<void> => {
|
||||
onTTSStart();
|
||||
started = true;
|
||||
const persistPayload = await resolveSegmentsForPersist(payload);
|
||||
const persistResult = await resolveSegmentsForPersist(payload);
|
||||
const persistPayload = persistResult.segments;
|
||||
if (persistPayload.length === 0) return;
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
|
|
@ -2303,18 +2323,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
if (generationAtStart !== epubPreloadGenerationRef.current) return;
|
||||
|
||||
const segmentLookup = new Map<string, TTSSegmentManifestItem>();
|
||||
for (const segment of ensured.segments) {
|
||||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) continue;
|
||||
if (!segment.locator) continue;
|
||||
segmentLookup.set(segment.segmentKey || `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, segment);
|
||||
}
|
||||
|
||||
for (const candidate of uniqueCandidates) {
|
||||
const segment = segmentLookup.get(candidate.segmentKey);
|
||||
if (!segment) continue;
|
||||
ensured.segments.forEach((segment, persistIndex) => {
|
||||
if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return;
|
||||
const sourceIndex = persistResult.sourceIndices[persistIndex];
|
||||
if (sourceIndex === undefined) return;
|
||||
const candidate = uniqueCandidates[sourceIndex];
|
||||
if (!candidate) return;
|
||||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
for (const candidate of uniqueCandidates) {
|
||||
|
|
@ -2373,9 +2389,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
cacheKey: string;
|
||||
}> = [];
|
||||
|
||||
const currentLocator: TTSSegmentLocator = isEPUB
|
||||
? { location: String(currDocPage), readerType: currentReaderType }
|
||||
: { page: Number(currDocPageNumber || 1), readerType: currentReaderType };
|
||||
const currentLocator: TTSSegmentLocator = locatorForLocation(
|
||||
isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1),
|
||||
currentReaderType,
|
||||
);
|
||||
|
||||
for (let offset = 1; offset <= sentenceLookahead; offset += 1) {
|
||||
const sentenceIndex = currentIndex + offset;
|
||||
|
|
@ -2407,9 +2424,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
: null;
|
||||
if (location === null) continue;
|
||||
|
||||
const locator: TTSSegmentLocator = typeof location === 'string'
|
||||
? { location, readerType: currentReaderType }
|
||||
: { page: Number(location || 1), readerType: currentReaderType };
|
||||
const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType);
|
||||
for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) {
|
||||
const segment = planned[index];
|
||||
const sentence = segment.text;
|
||||
|
|
@ -2471,7 +2486,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const preloadPromise = (async (): Promise<void> => {
|
||||
try {
|
||||
onTTSStart();
|
||||
const persistPayload = await resolveSegmentsForPersist(payload);
|
||||
const persistResult = await resolveSegmentsForPersist(payload);
|
||||
const persistPayload = persistResult.segments;
|
||||
if (persistPayload.length === 0) return;
|
||||
const ensured = await withRetry(
|
||||
async () => ensureTtsSegments({
|
||||
|
|
@ -2488,10 +2504,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
retryOptions,
|
||||
);
|
||||
|
||||
ensured.segments.forEach((segment) => {
|
||||
const candidate = candidateLookup.get(
|
||||
candidateLookupKey(segment.segmentIndex, segment.segmentKey),
|
||||
);
|
||||
ensured.segments.forEach((segment, persistIndex) => {
|
||||
const sourceIndex = persistResult.sourceIndices[persistIndex];
|
||||
if (sourceIndex === undefined) return;
|
||||
const candidate = uniqueCandidates[sourceIndex]
|
||||
?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey));
|
||||
if (!candidate) return;
|
||||
cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSc
|
|||
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
|
||||
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -109,19 +109,61 @@ export const documentPreviews = pgTable('document_previews', {
|
|||
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
|
||||
]);
|
||||
|
||||
export const ttsSegments = pgTable('tts_segments', {
|
||||
segmentId: text('segment_id').notNull(),
|
||||
export const ttsSegmentEntries = pgTable('tts_segment_entries', {
|
||||
segmentEntryId: text('segment_entry_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
documentId: text('document_id').notNull(),
|
||||
readerType: text('reader_type').notNull(),
|
||||
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
|
||||
segmentIndex: integer('segment_index').notNull(),
|
||||
segmentKey: text('segment_key'),
|
||||
locatorJson: text('locator_json'),
|
||||
settingsHash: text('settings_hash').notNull(),
|
||||
settingsJson: jsonb('settings_json').notNull(),
|
||||
locatorReaderRank: integer('locator_reader_rank').notNull(),
|
||||
locatorReaderType: text('locator_reader_type').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(),
|
||||
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'),
|
||||
audioFormat: text('audio_format').notNull().default('mp3'),
|
||||
durationMs: integer('duration_ms'),
|
||||
|
|
@ -132,6 +174,11 @@ export const ttsSegments = pgTable('tts_segments', {
|
|||
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.segmentId, table.userId] }),
|
||||
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash),
|
||||
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex),
|
||||
foreignKey({
|
||||
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),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -109,19 +109,61 @@ export const documentPreviews = sqliteTable('document_previews', {
|
|||
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
|
||||
]);
|
||||
|
||||
export const ttsSegments = sqliteTable('tts_segments', {
|
||||
segmentId: text('segment_id').notNull(),
|
||||
export const ttsSegmentEntries = sqliteTable('tts_segment_entries', {
|
||||
segmentEntryId: text('segment_entry_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
documentId: text('document_id').notNull(),
|
||||
readerType: text('reader_type').notNull(),
|
||||
documentVersion: integer('document_version').notNull(),
|
||||
segmentIndex: integer('segment_index').notNull(),
|
||||
segmentKey: text('segment_key'),
|
||||
locatorJson: text('locator_json'),
|
||||
settingsHash: text('settings_hash').notNull(),
|
||||
settingsJson: text('settings_json').notNull(),
|
||||
locatorReaderRank: integer('locator_reader_rank').notNull(),
|
||||
locatorReaderType: text('locator_reader_type').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(),
|
||||
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'),
|
||||
audioFormat: text('audio_format').notNull().default('mp3'),
|
||||
durationMs: integer('duration_ms'),
|
||||
|
|
@ -132,6 +174,11 @@ export const ttsSegments = sqliteTable('tts_segments', {
|
|||
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.segmentId, table.userId] }),
|
||||
index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash),
|
||||
index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex),
|
||||
foreignKey({
|
||||
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),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
|
|||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { ttsSegments } from '@/db/schema';
|
||||
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
|
||||
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
|
||||
|
||||
export type ResolvedSegmentAudio = {
|
||||
|
|
@ -41,15 +41,19 @@ export async function resolveCompletedSegmentAudio(
|
|||
|
||||
const rows = (await db
|
||||
.select({
|
||||
audioKey: ttsSegments.audioKey,
|
||||
status: ttsSegments.status,
|
||||
audioKey: ttsSegmentVariants.audioKey,
|
||||
status: ttsSegmentVariants.status,
|
||||
})
|
||||
.from(ttsSegments)
|
||||
.from(ttsSegmentVariants)
|
||||
.innerJoin(ttsSegmentEntries, and(
|
||||
eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId),
|
||||
eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId),
|
||||
))
|
||||
.where(and(
|
||||
eq(ttsSegments.userId, scope.storageUserId),
|
||||
eq(ttsSegments.documentId, documentId),
|
||||
eq(ttsSegments.documentVersion, scope.documentVersion),
|
||||
eq(ttsSegments.segmentId, segmentId),
|
||||
eq(ttsSegmentVariants.userId, scope.storageUserId),
|
||||
eq(ttsSegmentEntries.documentId, documentId),
|
||||
eq(ttsSegmentEntries.documentVersion, scope.documentVersion),
|
||||
eq(ttsSegmentVariants.segmentId, segmentId),
|
||||
))) as Array<{ audioKey: string | null; status: string }>;
|
||||
|
||||
const row = rows[0];
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ export const DEFAULT_PAGE_SIZE = 150;
|
|||
export const MIN_PAGE_SIZE = 25;
|
||||
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 {
|
||||
if (status === 'completed') return 3;
|
||||
if (status === 'pending') return 2;
|
||||
|
|
@ -45,24 +57,49 @@ export function compareManifestSegments(
|
|||
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;
|
||||
try {
|
||||
const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
|
||||
if (!decoded) return null;
|
||||
// Reject malformed input that Node's base64url decoder may parse into gibberish.
|
||||
const normalizedInput = cursor.replace(/=+$/, '');
|
||||
if (encodeManifestCursor(decoded) !== normalizedInput) {
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(decoded);
|
||||
} catch {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeManifestCursor(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url');
|
||||
export function encodeManifestCursor(value: TTSSegmentManifestCursor): string {
|
||||
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function parseManifestPageSize(value: string | null): number {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
|
|||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
|
||||
import type {
|
||||
TTSSegmentLocator,
|
||||
|
|
@ -54,6 +55,17 @@ export function normalizeSegmentText(text: string): string {
|
|||
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
|
||||
* stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) — the
|
||||
|
|
@ -104,27 +116,51 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg
|
|||
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 {
|
||||
if (!locator) return '';
|
||||
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: {
|
||||
documentId: string;
|
||||
documentVersion: number;
|
||||
|
|
@ -146,6 +182,25 @@ export function buildTtsSegmentId(input: {
|
|||
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 {
|
||||
return createHmac('sha256', secret).update(text).digest('hex');
|
||||
}
|
||||
|
|
@ -160,7 +215,7 @@ export function buildTtsSegmentAudioKey(input: {
|
|||
segmentId: string;
|
||||
}): string {
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -92,8 +92,10 @@ export async function deleteUserStorageData(
|
|||
try {
|
||||
const cfg = getS3Config();
|
||||
const nsSegment = namespace ? `ns/${namespace}/` : '';
|
||||
const ttsPrefix = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`;
|
||||
segmentsDeleted = await deleteTtsSegmentPrefix(ttsPrefix);
|
||||
const ttsPrefixV1 = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`;
|
||||
const ttsPrefixV2 = `${cfg.prefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(userId)}/`;
|
||||
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1);
|
||||
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
|
||||
} catch (error) {
|
||||
console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ export default async function globalTeardown(): Promise<void> {
|
|||
const config = getS3Config();
|
||||
const docsNsRootPrefix = `${config.prefix}/documents_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).
|
||||
const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix);
|
||||
|
|
@ -105,5 +106,6 @@ export default async function globalTeardown(): Promise<void> {
|
|||
|
||||
await deleteDocumentPrefix(docsNsRootPrefix);
|
||||
await deleteAudiobookPrefix(audiobooksNsRootPrefix);
|
||||
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefix);
|
||||
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1);
|
||||
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,10 +157,22 @@ test.describe('tts segments manifest helpers', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
expect(decodeManifestCursor(encoded)).toBe(raw);
|
||||
expect(decodeManifestCursor(encoded)).toEqual(raw);
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
buildProportionalAlignment,
|
||||
buildTtsSegmentEntryId,
|
||||
buildTtsSegmentId,
|
||||
buildTtsSegmentSettingsHash,
|
||||
buildTtsSegmentTextHash,
|
||||
locatorFingerprint,
|
||||
normalizeLocator,
|
||||
normalizeSegmentText,
|
||||
projectSegmentLocator,
|
||||
} from '../../src/lib/server/tts/segments';
|
||||
|
||||
test.describe('tts segment helpers', () => {
|
||||
|
|
@ -57,6 +59,35 @@ test.describe('tts segment helpers', () => {
|
|||
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', () => {
|
||||
const id1 = buildTtsSegmentId({
|
||||
documentId: 'doc',
|
||||
|
|
@ -97,8 +128,15 @@ test.describe('tts segment helpers', () => {
|
|||
|
||||
test('normalizes PDF locators and creates fingerprints', () => {
|
||||
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(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', () => {
|
||||
|
|
@ -110,6 +148,7 @@ test.describe('tts segment helpers', () => {
|
|||
charOffset: 128.4,
|
||||
cfi: ' epubcfi(/6/4!/4:0) ',
|
||||
});
|
||||
if (!stable) throw new Error('expected normalized epub locator');
|
||||
expect(stable).toEqual({
|
||||
readerType: 'epub',
|
||||
spineHref: 'OEBPS/ch02.xhtml',
|
||||
|
|
@ -118,6 +157,14 @@ test.describe('tts segment helpers', () => {
|
|||
cfi: 'epubcfi(/6/4!/4:0)',
|
||||
});
|
||||
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
|
||||
// rejected so we never persist viewport-dependent locators.
|
||||
|
|
@ -125,6 +172,15 @@ test.describe('tts segment helpers', () => {
|
|||
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', () => {
|
||||
const alignment = buildProportionalAlignment({
|
||||
sentence: normalizeSegmentText('Hello world again'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue