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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue