From 8488ad37e1dd0baad1cf78a5d1923cc4e12edd80 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 12 May 2026 17:44:57 -0600 Subject: [PATCH] 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. --- .../postgres/0003_tts_segments_v2_split.sql | 50 + drizzle/postgres/meta/0003_snapshot.json | 1582 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0003_tts_segments_v2_split.sql | 50 + drizzle/sqlite/meta/0003_snapshot.json | 1435 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + src/app/api/tts/segments/clear/route.ts | 26 +- src/app/api/tts/segments/ensure/route.ts | 241 ++- src/app/api/tts/segments/manifest/route.ts | 293 ++- src/components/reader/SegmentsSidebar.tsx | 2 - src/contexts/TTSContext.tsx | 89 +- src/db/schema.ts | 3 +- src/db/schema_postgres.ts | 61 +- src/db/schema_sqlite.ts | 61 +- src/lib/server/tts/segments-audio.ts | 20 +- src/lib/server/tts/segments-manifest.ts | 51 +- src/lib/server/tts/segments.ts | 89 +- src/lib/server/user/data-cleanup.ts | 6 +- tests/global-teardown.ts | 6 +- tests/unit/tts-segments-manifest.spec.ts | 16 +- tests/unit/tts-segments.spec.ts | 56 + 21 files changed, 3853 insertions(+), 298 deletions(-) create mode 100644 drizzle/postgres/0003_tts_segments_v2_split.sql create mode 100644 drizzle/postgres/meta/0003_snapshot.json create mode 100644 drizzle/sqlite/0003_tts_segments_v2_split.sql create mode 100644 drizzle/sqlite/meta/0003_snapshot.json diff --git a/drizzle/postgres/0003_tts_segments_v2_split.sql b/drizzle/postgres/0003_tts_segments_v2_split.sql new file mode 100644 index 0000000..823ed6a --- /dev/null +++ b/drizzle/postgres/0003_tts_segments_v2_split.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0003_snapshot.json b/drizzle/postgres/meta/0003_snapshot.json new file mode 100644 index 0000000..e0f1a47 --- /dev/null +++ b/drizzle/postgres/meta/0003_snapshot.json @@ -0,0 +1,1582 @@ +{ + "id": "9b4f6d81-94e7-450c-bf71-e3fa5da5fd4e", + "prevId": "8aed8970-1fa7-44fd-b13c-30574bbcfea8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 8d619a9..81448f2 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1778502597852, "tag": "0002_add_segment_key_to_tts_segments", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1778627816569, + "tag": "0003_tts_segments_v2_split", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0003_tts_segments_v2_split.sql b/drizzle/sqlite/0003_tts_segments_v2_split.sql new file mode 100644 index 0000000..5ff53b9 --- /dev/null +++ b/drizzle/sqlite/0003_tts_segments_v2_split.sql @@ -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`; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0003_snapshot.json b/drizzle/sqlite/meta/0003_snapshot.json new file mode 100644 index 0000000..d4f899a --- /dev/null +++ b/drizzle/sqlite/meta/0003_snapshot.json @@ -0,0 +1,1435 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "41858afa-f0b8-447c-af72-9e7e1ead3413", + "prevId": "7587ff4b-f7d8-4e20-8ba9-1d5c90e1d3c5", + "tables": { + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "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" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 40e766c..dc2fd9c 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1778502597591, "tag": "0002_add_segment_key_to_tts_segments", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1778627810476, + "tag": "0003_tts_segments_v2_split", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index 2f6fdea..89a07ec 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; @@ -27,22 +27,26 @@ export async function POST(request: NextRequest) { const rows = (await db .select({ - segmentId: ttsSegments.segmentId, - audioKey: ttsSegments.audioKey, + segmentId: ttsSegmentVariants.segmentId, + audioKey: ttsSegmentVariants.audioKey, }) - .from(ttsSegments) + .from(ttsSegmentVariants) + .innerJoin(ttsSegmentEntries, and( + eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), + eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), + )) .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, parsed.documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), + eq(ttsSegmentEntries.userId, scope.storageUserId), + eq(ttsSegmentEntries.documentId, parsed.documentId), + eq(ttsSegmentEntries.documentVersion, scope.documentVersion), ))) as Array<{ segmentId: string; audioKey: string | null }>; await db - .delete(ttsSegments) + .delete(ttsSegmentEntries) .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, parsed.documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), + eq(ttsSegmentEntries.userId, scope.storageUserId), + eq(ttsSegmentEntries.documentId, parsed.documentId), + eq(ttsSegmentEntries.documentVersion, scope.documentVersion), )); const audioKeys = rows diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index ea647ce..00642cc 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -1,25 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq, inArray, ne } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { isS3Configured, getS3Config } from '@/lib/server/storage/s3'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; import { - deleteTtsSegmentAudioObjects, getTtsSegmentAudioObject, putTtsSegmentAudioObject, } from '@/lib/server/tts/segments-blobstore'; import { buildTtsSegmentAudioKey, + buildTtsSegmentEntryId, buildTtsSegmentId, buildTtsSegmentSettingsHash, buildTtsSegmentSettingsJson, buildTtsSegmentTextHash, - canonicalLocatorJson, - canonicalizeLocatorJsonString, locatorFingerprint, normalizeLocator, normalizeSegmentText, + projectSegmentLocator, probeAudioDurationMsFromBuffer, } from '@/lib/server/tts/segments'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; @@ -140,67 +139,24 @@ function isAbortLikeError(error: unknown): boolean { return /abort/i.test(message); } -async function cleanupStaleCanonicalVariants(input: { - userId: string; - documentId: string; - documentVersion: number; - segmentIndex: number; - activeLocatorJson: string | null; - activeSegmentId: string; - activeSettingsHash: string; -}): Promise { - const staleRows = (await db - .select({ - segmentId: ttsSegments.segmentId, - settingsHash: ttsSegments.settingsHash, - audioKey: ttsSegments.audioKey, - locatorJson: ttsSegments.locatorJson, - }) - .from(ttsSegments) +async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Promise { + const stillReferenced = await db + .select({ segmentId: ttsSegmentVariants.segmentId }) + .from(ttsSegmentVariants) .where(and( - eq(ttsSegments.userId, input.userId), - eq(ttsSegments.documentId, input.documentId), - eq(ttsSegments.documentVersion, input.documentVersion), - eq(ttsSegments.segmentIndex, input.segmentIndex), - ne(ttsSegments.segmentId, input.activeSegmentId), - ))) as Array<{ - segmentId: string; - settingsHash: string; - audioKey: string | null; - locatorJson: string | null; - }>; + eq(ttsSegmentVariants.userId, userId), + eq(ttsSegmentVariants.segmentEntryId, segmentEntryId), + )) + .limit(1); - const activeCanonicalLocator = canonicalizeLocatorJsonString(input.activeLocatorJson); - const staleRowsForSettings = staleRows.filter((row) => - row.settingsHash === input.activeSettingsHash - && canonicalizeLocatorJsonString(row.locatorJson) === activeCanonicalLocator, - ); - const staleIds = staleRowsForSettings.map((row) => row.segmentId); - - if (staleIds.length === 0) return; + if (stillReferenced.length > 0) return; await db - .delete(ttsSegments) + .delete(ttsSegmentEntries) .where(and( - eq(ttsSegments.userId, input.userId), - inArray(ttsSegments.segmentId, staleIds), + eq(ttsSegmentEntries.userId, userId), + eq(ttsSegmentEntries.segmentEntryId, segmentEntryId), )); - - const staleAudioKeys = Array.from(new Set(staleRowsForSettings - .map((row) => row.audioKey) - .filter((key): key is string => Boolean(key)))); - if (staleAudioKeys.length > 0) { - try { - await deleteTtsSegmentAudioObjects(staleAudioKeys); - } catch (error) { - console.warn('Failed deleting stale TTS segment audio objects:', { - documentId: input.documentId, - userId: input.userId, - segmentIndex: input.segmentIndex, - error: error instanceof Error ? error.message : String(error), - }); - } - } } export async function POST(request: NextRequest) { @@ -223,11 +179,16 @@ export async function POST(request: NextRequest) { const storagePrefix = getS3Config().prefix; const secret = textHmacSecret(); + let invalidLocatorIndex = -1; const normalized = parsed.segments - .map((segment) => { + .map((segment, index) => { const text = normalizeSegmentText(segment.text); if (!text) return null; const locator = normalizeLocator(segment.locator); + if (!locator) { + invalidLocatorIndex = index; + return null; + } const locatorHash = locatorFingerprint(locator); const segmentId = buildTtsSegmentId({ documentId: parsed.documentId, @@ -249,6 +210,13 @@ export async function POST(request: NextRequest) { }) .filter((value): value is NonNullable => Boolean(value)); + if (invalidLocatorIndex >= 0) { + return NextResponse.json( + { error: `Invalid or unsupported segment locator at index ${invalidLocatorIndex}` }, + { status: 400 }, + ); + } + if (normalized.length === 0) { return NextResponse.json({ error: 'No valid non-empty segments provided' }, { status: 400 }); } @@ -256,19 +224,16 @@ export async function POST(request: NextRequest) { const ids = normalized.map((segment) => segment.segmentId); const rows = (await db .select() - .from(ttsSegments) - .where(and(eq(ttsSegments.userId, scope.storageUserId), inArray(ttsSegments.segmentId, ids)))) as Array<{ + .from(ttsSegmentVariants) + .where(and( + eq(ttsSegmentVariants.userId, scope.storageUserId), + inArray(ttsSegmentVariants.segmentId, ids), + ))) as Array<{ segmentId: string; userId: string; - documentId: string; - readerType: string; - documentVersion: number; - segmentIndex: number; - locatorJson: string | null; + segmentEntryId: string; settingsHash: string; settingsJson: unknown; - textHash: string; - textLength: number; audioKey: string | null; audioFormat: string; durationMs: number | null; @@ -283,25 +248,74 @@ export async function POST(request: NextRequest) { const manifest: TTSSegmentManifestItem[] = []; for (const segment of normalized) { - const existing = existingById.get(segment.segmentId); + const locatorProjection = projectSegmentLocator(segment.locator); + const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim() + ? segment.original.segmentKey.trim() + : null; + const segmentEntryId = buildTtsSegmentEntryId({ + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + locatorIdentityKey: locatorProjection.locatorIdentityKey, + textHash: segment.textHash, + }); - if (existing?.status === 'completed' && existing.audioKey) { - await cleanupStaleCanonicalVariants({ + await db + .insert(ttsSegmentEntries) + .values({ + segmentEntryId, userId: scope.storageUserId, documentId: parsed.documentId, + readerType: scope.readerType, documentVersion: scope.documentVersion, segmentIndex: segment.original.segmentIndex, - activeLocatorJson: existing.locatorJson, - activeSegmentId: segment.segmentId, - activeSettingsHash: settingsHash, + segmentKey: segmentKeyForRow, + ...locatorProjection, + textHash: segment.textHash, + textLength: segment.text.length, + updatedAt: nowMs, + }) + .onConflictDoUpdate({ + target: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId], + set: { + documentId: parsed.documentId, + readerType: scope.readerType, + documentVersion: scope.documentVersion, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + ...locatorProjection, + textHash: segment.textHash, + textLength: segment.text.length, + updatedAt: nowMs, + }, }); + const existing = existingById.get(segment.segmentId); + const movedFromEntryId = existing && existing.segmentEntryId !== segmentEntryId + ? existing.segmentEntryId + : null; + + if (existing?.status === 'completed' && existing.audioKey) { + if (movedFromEntryId) { + await db + .update(ttsSegmentVariants) + .set({ + segmentEntryId, + settingsJson, + updatedAt: nowMs, + }) + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )); + await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId); + } + let alignment = existing.alignmentJson ? (JSON.parse(existing.alignmentJson) as TTSSegmentManifestItem['alignment']) : null; - const locator = existing.locatorJson - ? (JSON.parse(existing.locatorJson) as TTSSegmentManifestItem['locator']) - : null; + const locator = segment.locator; // Self-heal transient Whisper failures: if audio exists but alignment was // previously unavailable, retry alignment using the current segment text. @@ -319,12 +333,15 @@ export async function POST(request: NextRequest) { if (alignment) { await db - .update(ttsSegments) + .update(ttsSegmentVariants) .set({ alignmentJson: JSON.stringify(alignment), updatedAt: Date.now(), }) - .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )); } } catch (alignError) { console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', { @@ -337,8 +354,8 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, - segmentIndex: existing.segmentIndex, - segmentKey: segment.original.segmentKey ?? null, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs: existing.durationMs ?? 0, alignment, @@ -358,25 +375,14 @@ export async function POST(request: NextRequest) { segmentId: segment.segmentId, }); - const segmentLocatorJson = canonicalLocatorJson(segment.locator); - const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim() - ? segment.original.segmentKey.trim() - : null; await db - .insert(ttsSegments) + .insert(ttsSegmentVariants) .values({ segmentId: segment.segmentId, userId: scope.storageUserId, - documentId: parsed.documentId, - readerType: scope.readerType, - documentVersion: scope.documentVersion, - segmentIndex: segment.original.segmentIndex, - segmentKey: segmentKeyForRow, - locatorJson: segmentLocatorJson, + segmentEntryId, settingsHash, settingsJson, - textHash: segment.textHash, - textLength: segment.text.length, audioKey, audioFormat: 'mp3', status: 'pending', @@ -384,18 +390,11 @@ export async function POST(request: NextRequest) { updatedAt: nowMs, }) .onConflictDoUpdate({ - target: [ttsSegments.segmentId, ttsSegments.userId], + target: [ttsSegmentVariants.segmentId, ttsSegmentVariants.userId], set: { - documentId: parsed.documentId, - readerType: scope.readerType, - documentVersion: scope.documentVersion, - segmentIndex: segment.original.segmentIndex, - segmentKey: segmentKeyForRow, - locatorJson: segmentLocatorJson, + segmentEntryId, settingsHash, settingsJson, - textHash: segment.textHash, - textLength: segment.text.length, audioKey, audioFormat: 'mp3', status: 'pending', @@ -404,6 +403,10 @@ export async function POST(request: NextRequest) { }, }); + if (movedFromEntryId) { + await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId); + } + try { if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) { const charCount = segment.text.length; @@ -493,7 +496,7 @@ export async function POST(request: NextRequest) { } await db - .update(ttsSegments) + .update(ttsSegmentVariants) .set({ durationMs, alignmentJson: alignment ? JSON.stringify(alignment) : null, @@ -501,22 +504,15 @@ export async function POST(request: NextRequest) { error: null, updatedAt: Date.now(), }) - .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); - - await cleanupStaleCanonicalVariants({ - userId: scope.storageUserId, - documentId: parsed.documentId, - documentVersion: scope.documentVersion, - segmentIndex: segment.original.segmentIndex, - activeLocatorJson: canonicalLocatorJson(segment.locator), - activeSegmentId: segment.segmentId, - activeSettingsHash: settingsHash, - }); + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )); manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - segmentKey: segment.original.segmentKey ?? null, + segmentKey: segmentKeyForRow, ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs, alignment, @@ -527,18 +523,21 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : 'Failed to generate segment'; const aborted = isAbortLikeError(error); await db - .update(ttsSegments) + .update(ttsSegmentVariants) .set({ status: aborted ? 'pending' : 'error', error: aborted ? null : message, updatedAt: Date.now(), }) - .where(and(eq(ttsSegments.segmentId, segment.segmentId), eq(ttsSegments.userId, scope.storageUserId))); + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )); manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - segmentKey: segment.original.segmentKey ?? null, + segmentKey: segmentKeyForRow, audioPresignUrl: null, audioFallbackUrl: null, durationMs: 0, diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 89d2ae3..78e4a3e 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -1,15 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, asc, eq } from 'drizzle-orm'; +import { and, asc, eq, gt, inArray, or } from 'drizzle-orm'; import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { - compareManifestSegments, decodeManifestCursor, dedupeManifestVariants, encodeManifestCursor, - locatorIdentityKey, parseManifestPageSize, + type TTSSegmentManifestCursor, } from '@/lib/server/tts/segments-manifest'; import type { TTSSegmentLocator, @@ -22,6 +21,21 @@ import type { export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +type ManifestGroupRow = { + segmentEntryId: string; + segmentIndex: number; + segmentKey: string | null; + textLength: number; + locatorReaderRank: number; + locatorReaderType: string; + locatorPage: number; + locatorSpineIndex: number; + locatorSpineHref: string; + locatorCharOffset: number; + locatorLocation: string; + locatorIdentityKey: string; +}; + function parseSettingsValue(value: unknown): TTSSegmentSettings | null { let raw: unknown = value; if (typeof raw === 'string') { @@ -30,9 +44,6 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null { if (!raw || typeof raw !== 'object') return null; const rec = raw as Record; - // Settings stored via buildTtsSegmentSettingsJson — accept either the runtime - // shape (ttsProvider/ttsModel/voice/nativeSpeed/ttsInstructions) or the - // canonical hash form (provider/model/voice/speed/instructions/format). const ttsProvider = typeof rec.ttsProvider === 'string' ? rec.ttsProvider : typeof rec.provider === 'string' ? rec.provider : null; @@ -49,16 +60,22 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null { return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions }; } -function parseLocator(value: unknown): TTSSegmentLocator | null { - if (!value) return null; - if (typeof value !== 'string') return value as TTSSegmentLocator; - try { - const parsed = JSON.parse(value); - if (!parsed || typeof parsed !== 'object') return null; - return parsed as TTSSegmentLocator; - } catch { - return null; +function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null { + if (row.locatorReaderType === 'epub' && row.locatorSpineIndex >= 0 && row.locatorCharOffset >= 0 && row.locatorSpineHref) { + return { + readerType: 'epub', + spineIndex: row.locatorSpineIndex, + spineHref: row.locatorSpineHref, + charOffset: row.locatorCharOffset, + }; } + if (row.locatorReaderType === 'pdf' && row.locatorPage >= 1) { + return { readerType: 'pdf', page: row.locatorPage }; + } + if (row.locatorReaderType === 'html' && row.locatorLocation) { + return { readerType: 'html', location: row.locatorLocation }; + } + return null; } function buildSegmentAudioUrls(documentId: string, segmentId: string): { @@ -78,6 +95,86 @@ function isAbortLikeMessage(message: string | null | undefined): boolean { return /abort/i.test(message); } +function buildKeysetWhere(cursor: TTSSegmentManifestCursor) { + return or( + gt(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + gt(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + gt(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + gt(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + gt(ttsSegmentEntries.locatorPage, cursor.locatorPage), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + eq(ttsSegmentEntries.locatorPage, cursor.locatorPage), + gt(ttsSegmentEntries.locatorLocation, cursor.locatorLocation), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + eq(ttsSegmentEntries.locatorPage, cursor.locatorPage), + eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation), + gt(ttsSegmentEntries.segmentIndex, cursor.segmentIndex), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + eq(ttsSegmentEntries.locatorPage, cursor.locatorPage), + eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation), + eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex), + gt(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey), + ), + and( + eq(ttsSegmentEntries.locatorReaderRank, cursor.locatorReaderRank), + eq(ttsSegmentEntries.locatorSpineIndex, cursor.locatorSpineIndex), + eq(ttsSegmentEntries.locatorCharOffset, cursor.locatorCharOffset), + eq(ttsSegmentEntries.locatorSpineHref, cursor.locatorSpineHref), + eq(ttsSegmentEntries.locatorPage, cursor.locatorPage), + eq(ttsSegmentEntries.locatorLocation, cursor.locatorLocation), + eq(ttsSegmentEntries.segmentIndex, cursor.segmentIndex), + eq(ttsSegmentEntries.locatorIdentityKey, cursor.locatorIdentityKey), + gt(ttsSegmentEntries.segmentEntryId, cursor.segmentEntryId), + ), + ); +} + +function cursorFromGroupRow(row: ManifestGroupRow): TTSSegmentManifestCursor { + return { + locatorReaderRank: row.locatorReaderRank, + locatorSpineIndex: row.locatorSpineIndex, + locatorCharOffset: row.locatorCharOffset, + locatorSpineHref: row.locatorSpineHref, + locatorPage: row.locatorPage, + locatorLocation: row.locatorLocation, + segmentIndex: row.segmentIndex, + locatorIdentityKey: row.locatorIdentityKey, + segmentEntryId: row.segmentEntryId, + }; +} + export async function GET(request: NextRequest) { try { const documentIdRaw = request.nextUrl.searchParams.get('documentId'); @@ -91,27 +188,92 @@ export async function GET(request: NextRequest) { const scope = await resolveSegmentDocumentScope(request, documentId); if (scope instanceof Response) return scope; - const rows = (await db - .select() - .from(ttsSegments) - .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), + const scopeWhere = and( + eq(ttsSegmentEntries.userId, scope.storageUserId), + eq(ttsSegmentEntries.documentId, documentId), + eq(ttsSegmentEntries.documentVersion, scope.documentVersion), + ); + + const groupWhere = cursor + ? and(scopeWhere, buildKeysetWhere(cursor)) + : scopeWhere; + + const groupedRows = (await db + .select({ + segmentEntryId: ttsSegmentEntries.segmentEntryId, + segmentIndex: ttsSegmentEntries.segmentIndex, + segmentKey: ttsSegmentEntries.segmentKey, + textLength: ttsSegmentEntries.textLength, + locatorReaderRank: ttsSegmentEntries.locatorReaderRank, + locatorReaderType: ttsSegmentEntries.locatorReaderType, + locatorPage: ttsSegmentEntries.locatorPage, + locatorSpineIndex: ttsSegmentEntries.locatorSpineIndex, + locatorSpineHref: ttsSegmentEntries.locatorSpineHref, + locatorCharOffset: ttsSegmentEntries.locatorCharOffset, + locatorLocation: ttsSegmentEntries.locatorLocation, + locatorIdentityKey: ttsSegmentEntries.locatorIdentityKey, + }) + .from(ttsSegmentEntries) + .innerJoin(ttsSegmentVariants, and( + eq(ttsSegmentVariants.segmentEntryId, ttsSegmentEntries.segmentEntryId), + eq(ttsSegmentVariants.userId, ttsSegmentEntries.userId), )) - .orderBy(asc(ttsSegments.segmentIndex), asc(ttsSegments.updatedAt))) as Array<{ + .where(groupWhere) + .groupBy( + ttsSegmentEntries.segmentEntryId, + ttsSegmentEntries.segmentIndex, + ttsSegmentEntries.segmentKey, + ttsSegmentEntries.textLength, + ttsSegmentEntries.locatorReaderRank, + ttsSegmentEntries.locatorReaderType, + ttsSegmentEntries.locatorPage, + ttsSegmentEntries.locatorSpineIndex, + ttsSegmentEntries.locatorSpineHref, + ttsSegmentEntries.locatorCharOffset, + ttsSegmentEntries.locatorLocation, + ttsSegmentEntries.locatorIdentityKey, + ) + .orderBy( + asc(ttsSegmentEntries.locatorReaderRank), + asc(ttsSegmentEntries.locatorSpineIndex), + asc(ttsSegmentEntries.locatorCharOffset), + asc(ttsSegmentEntries.locatorSpineHref), + asc(ttsSegmentEntries.locatorPage), + asc(ttsSegmentEntries.locatorLocation), + asc(ttsSegmentEntries.segmentIndex), + asc(ttsSegmentEntries.locatorIdentityKey), + asc(ttsSegmentEntries.segmentEntryId), + ) + .limit(limit + 1)) as ManifestGroupRow[]; + + const hasMore = groupedRows.length > limit; + const pageGroups = hasMore ? groupedRows.slice(0, limit) : groupedRows; + if (pageGroups.length === 0) { + const emptyResponse: TTSSegmentsManifestResponse = { + documentId, + segments: [], + nextCursor: null, + hasMore: false, + }; + return NextResponse.json(emptyResponse); + } + + const entryIds = pageGroups.map((row) => row.segmentEntryId); + const entryById = new Map(pageGroups.map((entry) => [entry.segmentEntryId, entry])); + + const variantRows = (await db + .select() + .from(ttsSegmentVariants) + .where(and( + eq(ttsSegmentVariants.userId, scope.storageUserId), + inArray(ttsSegmentVariants.segmentEntryId, entryIds), + )) + .orderBy(asc(ttsSegmentVariants.segmentEntryId), asc(ttsSegmentVariants.updatedAt))) as Array<{ segmentId: string; userId: string; - documentId: string; - readerType: string; - documentVersion: number; - segmentIndex: number; - segmentKey: string | null; - locatorJson: string | null; + segmentEntryId: string; settingsHash: string; settingsJson: unknown; - textHash: string; - textLength: number; audioKey: string | null; audioFormat: string; durationMs: number | null; @@ -126,26 +288,21 @@ export async function GET(request: NextRequest) { variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>; }>(); - for (const row of rows) { - const locator = parseLocator(row.locatorJson); - // Use the per-row identity key (not the coarse sidebar group key) so two - // persisted rows in the same chapter at different `charOffset`s remain - // distinct entries instead of collapsing into one bucket whose locator - // only reflects the first row seen. - const groupKey = `${row.segmentIndex}|${locatorIdentityKey(locator)}`; + for (const row of variantRows) { + const entryMeta = entryById.get(row.segmentEntryId); + if (!entryMeta) continue; + const key = row.segmentEntryId; + const locator = locatorFromProjection(entryMeta); - let entry = grouped.get(groupKey); + let entry = grouped.get(key); if (!entry) { entry = { - segmentIndex: row.segmentIndex, - segmentKey: row.segmentKey, + segmentIndex: entryMeta.segmentIndex, + segmentKey: entryMeta.segmentKey, locator, variants: [], }; - grouped.set(groupKey, entry); - } else { - if (!entry.locator) entry.locator = locator; - if (!entry.segmentKey && row.segmentKey) entry.segmentKey = row.segmentKey; + grouped.set(key, entry); } let alignmentWordCount = 0; @@ -177,7 +334,7 @@ export async function GET(request: NextRequest) { audioFallbackUrl: audioUrls.audioFallbackUrl, durationMs: row.durationMs, status, - textLength: row.textLength, + textLength: entryMeta.textLength, alignmentWordCount, audioKey: row.audioKey, updatedAt: row.updatedAt, @@ -185,39 +342,25 @@ export async function GET(request: NextRequest) { }); } - const segments = Array.from(grouped.entries()) - .map(([groupKey, segment]) => ({ - groupKey, - segmentIndex: segment.segmentIndex, - segmentKey: segment.segmentKey, - locator: segment.locator, - variants: dedupeManifestVariants(segment.variants), - })) - .sort(compareManifestSegments); + const segments = pageGroups + .map((row) => { + const key = row.segmentEntryId; + const entry = grouped.get(key); + return { + segmentIndex: row.segmentIndex, + segmentKey: row.segmentKey ?? null, + locator: entry?.locator || locatorFromProjection(row), + variants: dedupeManifestVariants(entry?.variants || []), + }; + }); - let startIndex = 0; - if (cursor) { - const cursorIndex = segments.findIndex((segment) => segment.groupKey === cursor); - if (cursorIndex < 0) { - return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 }); - } - if (cursorIndex >= 0) startIndex = cursorIndex + 1; - } - - const page = segments.slice(startIndex, startIndex + limit); - const hasMore = startIndex + page.length < segments.length; - const nextCursor = hasMore && page.length > 0 - ? encodeManifestCursor(page[page.length - 1].groupKey) + const nextCursor = hasMore + ? encodeManifestCursor(cursorFromGroupRow(pageGroups[pageGroups.length - 1])) : null; const response: TTSSegmentsManifestResponse = { documentId, - segments: page.map((segment) => ({ - segmentIndex: segment.segmentIndex, - segmentKey: segment.segmentKey ?? null, - locator: segment.locator, - variants: segment.variants, - })), + segments, nextCursor, hasMore, }; diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index f7bfce5..ce93920 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -44,7 +44,6 @@ type FetchState = | { kind: 'ready'; data: TTSSegmentRow[]; - fetchedAt: number; nextCursor: string | null; hasMore: boolean; loadingMore: boolean; @@ -290,7 +289,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId }: SegmentsSideb return { kind: 'ready', data: merged, - fetchedAt: Date.now(), nextCursor: data.nextCursor, hasMore: data.hasMore, loadingMore: false, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 4086426..e2177f3 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -177,6 +177,11 @@ type EpubLocationPreloadCandidate = { cacheKey: string; }; +type PersistResolution = { + segments: TTSSegmentInput[]; + sourceIndices: number[]; +}; + type JumpResolutionInput = { isEPUB: boolean; newSentenceCount: number; @@ -313,6 +318,9 @@ const locatorForLocation = ( if (typeof location === 'string') { return { location, readerType }; } + if (readerType === 'html') { + return { location: String(location || 1), readerType }; + } return { page: Math.max(1, Math.floor(Number(location || 1))), readerType }; }; @@ -425,13 +433,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement */ const resolveSegmentsForPersist = useCallback(async ( segments: TTSSegmentInput[], - ): Promise => { + ): Promise => { const resolver = epubLocatorResolverRef.current; const out: TTSSegmentInput[] = []; - for (const segment of segments) { + const sourceIndices: number[] = []; + for (let idx = 0; idx < segments.length; idx += 1) { + const segment = segments[idx]; const locator = segment.locator; if (!locator || locator.readerType !== 'epub') { out.push(segment); + sourceIndices.push(idx); continue; } if (!resolver) { @@ -461,12 +472,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? { text: resolved.text } : {}), }); + sourceIndices.push(idx); } } catch (error) { console.warn('Failed to resolve EPUB locator; dropping segment', error); } } - return out; + return { + segments: out, + sourceIndices, + }; }, [documentId, ttsSegmentMaxBlockLength]); /** @@ -1376,9 +1391,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); - const locator = locatorOverride || (isEPUB - ? { location: String(currDocPage), readerType: currentReaderType } - : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); + const locator = locatorOverride || locatorForLocation( + isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), + currentReaderType, + ); const audioCacheKey = buildScopedSegmentCacheKey( locator, sentenceIndex, @@ -1432,7 +1448,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement try { onTTSStart(); - const persistSegments = await resolveSegmentsForPersist([ + const persistResult = await resolveSegmentsForPersist([ { segmentIndex: sentenceIndex, ...(segmentKey ? { segmentKey } : {}), @@ -1440,6 +1456,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement locator, }, ]); + const persistSegments = persistResult.segments; if (persistSegments.length === 0) { if (!preload) setIsPlaying(false); return undefined; @@ -1566,9 +1583,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement segmentKey?: string | null, ): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); - const locator = locatorOverride || (isEPUB - ? { location: String(currDocPage), readerType: currentReaderType } - : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); + const locator = locatorOverride || locatorForLocation( + isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), + currentReaderType, + ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, segmentKey); // Check if there's a pending preload request for this sentence @@ -1988,9 +2006,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement completedEpubBoundarySegmentRef.current = null; } const activeLocator: TTSSegmentLocator = playbackSegment?.ownerLocator - ?? (isEPUB - ? { location: String(currDocPage), readerType: currentReaderType } - : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }); + ?? locatorForLocation( + isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), + currentReaderType, + ); const alignmentKey = buildScopedSegmentCacheKey( activeLocator, currentIndex, @@ -2284,7 +2303,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const preloadPromise = (async (): Promise => { onTTSStart(); started = true; - const persistPayload = await resolveSegmentsForPersist(payload); + const persistResult = await resolveSegmentsForPersist(payload); + const persistPayload = persistResult.segments; if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ @@ -2303,18 +2323,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (generationAtStart !== epubPreloadGenerationRef.current) return; - const segmentLookup = new Map(); - for (const segment of ensured.segments) { - if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) continue; - if (!segment.locator) continue; - segmentLookup.set(segment.segmentKey || `${buildLocatorRequestKey(segment.locator)}::${segment.segmentIndex}`, segment); - } - - for (const candidate of uniqueCandidates) { - const segment = segmentLookup.get(candidate.segmentKey); - if (!segment) continue; + ensured.segments.forEach((segment, persistIndex) => { + if (segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) return; + const sourceIndex = persistResult.sourceIndices[persistIndex]; + if (sourceIndex === undefined) return; + const candidate = uniqueCandidates[sourceIndex]; + if (!candidate) return; cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); - } + }); })(); for (const candidate of uniqueCandidates) { @@ -2373,9 +2389,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement cacheKey: string; }> = []; - const currentLocator: TTSSegmentLocator = isEPUB - ? { location: String(currDocPage), readerType: currentReaderType } - : { page: Number(currDocPageNumber || 1), readerType: currentReaderType }; + const currentLocator: TTSSegmentLocator = locatorForLocation( + isEPUB ? String(currDocPage) : Number(currDocPageNumber || 1), + currentReaderType, + ); for (let offset = 1; offset <= sentenceLookahead; offset += 1) { const sentenceIndex = currentIndex + offset; @@ -2407,9 +2424,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : null; if (location === null) continue; - const locator: TTSSegmentLocator = typeof location === 'string' - ? { location, readerType: currentReaderType } - : { page: Number(location || 1), readerType: currentReaderType }; + const locator: TTSSegmentLocator = locatorForLocation(location, currentReaderType); for (let index = 0; index < Math.min(sentenceLookahead, planned.length); index += 1) { const segment = planned[index]; const sentence = segment.text; @@ -2471,7 +2486,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const preloadPromise = (async (): Promise => { try { onTTSStart(); - const persistPayload = await resolveSegmentsForPersist(payload); + const persistResult = await resolveSegmentsForPersist(payload); + const persistPayload = persistResult.segments; if (persistPayload.length === 0) return; const ensured = await withRetry( async () => ensureTtsSegments({ @@ -2488,10 +2504,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement retryOptions, ); - ensured.segments.forEach((segment) => { - const candidate = candidateLookup.get( - candidateLookupKey(segment.segmentIndex, segment.segmentKey), - ); + ensured.segments.forEach((segment, persistIndex) => { + const sourceIndex = persistResult.sourceIndices[persistIndex]; + if (sourceIndex === undefined) return; + const candidate = uniqueCandidates[sourceIndex] + ?? candidateLookup.get(candidateLookupKey(segment.segmentIndex, segment.segmentKey)); if (!candidate) return; cacheCompletedManifestForCandidate(candidate.cacheKey, segment, alignmentEnabledForCurrentDoc); }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 975b1e5..ae13a17 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -13,4 +13,5 @@ export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSc export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; -export const ttsSegments = usePostgres ? postgresSchema.ttsSegments : sqliteSchema.ttsSegments; +export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries; +export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index c07a7ae..c71af8f 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -109,19 +109,61 @@ export const documentPreviews = pgTable('document_previews', { index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), ]); -export const ttsSegments = pgTable('tts_segments', { - segmentId: text('segment_id').notNull(), +export const ttsSegmentEntries = pgTable('tts_segment_entries', { + segmentEntryId: text('segment_entry_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), readerType: text('reader_type').notNull(), documentVersion: bigint('document_version', { mode: 'number' }).notNull(), segmentIndex: integer('segment_index').notNull(), segmentKey: text('segment_key'), - locatorJson: text('locator_json'), - settingsHash: text('settings_hash').notNull(), - settingsJson: jsonb('settings_json').notNull(), + locatorReaderRank: integer('locator_reader_rank').notNull(), + locatorReaderType: text('locator_reader_type').notNull(), + locatorPage: integer('locator_page').notNull(), + locatorSpineIndex: integer('locator_spine_index').notNull(), + locatorSpineHref: text('locator_spine_href').notNull(), + locatorCharOffset: integer('locator_char_offset').notNull(), + locatorLocation: text('locator_location').notNull(), + locatorIdentityKey: text('locator_identity_key').notNull(), textHash: text('text_hash').notNull(), textLength: integer('text_length').notNull().default(0), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.segmentEntryId, table.userId] }), + index('idx_tts_segment_entries_manifest_sort').on( + table.userId, + table.documentId, + table.documentVersion, + table.locatorReaderRank, + table.locatorSpineIndex, + table.locatorCharOffset, + table.locatorSpineHref, + table.locatorPage, + table.locatorLocation, + table.segmentIndex, + table.locatorIdentityKey, + ), + index('idx_tts_segment_entries_manifest_group').on( + table.userId, + table.documentId, + table.documentVersion, + table.segmentIndex, + table.locatorIdentityKey, + ), + index('idx_tts_segment_entries_scope').on( + table.userId, + table.documentId, + table.documentVersion, + ), +]); + +export const ttsSegmentVariants = pgTable('tts_segment_variants', { + segmentId: text('segment_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + segmentEntryId: text('segment_entry_id').notNull(), + settingsHash: text('settings_hash').notNull(), + settingsJson: jsonb('settings_json').notNull(), audioKey: text('audio_key'), audioFormat: text('audio_format').notNull().default('mp3'), durationMs: integer('duration_ms'), @@ -132,6 +174,11 @@ export const ttsSegments = pgTable('tts_segments', { updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.segmentId, table.userId] }), - index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), - index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), + foreignKey({ + columns: [table.segmentEntryId, table.userId], + foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId], + }).onDelete('cascade'), + index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt), + index('idx_tts_segment_variants_status').on(table.userId, table.status), + index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash), ]); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index edb56e8..573d1fa 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -109,19 +109,61 @@ export const documentPreviews = sqliteTable('document_previews', { index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), ]); -export const ttsSegments = sqliteTable('tts_segments', { - segmentId: text('segment_id').notNull(), +export const ttsSegmentEntries = sqliteTable('tts_segment_entries', { + segmentEntryId: text('segment_entry_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), readerType: text('reader_type').notNull(), documentVersion: integer('document_version').notNull(), segmentIndex: integer('segment_index').notNull(), segmentKey: text('segment_key'), - locatorJson: text('locator_json'), - settingsHash: text('settings_hash').notNull(), - settingsJson: text('settings_json').notNull(), + locatorReaderRank: integer('locator_reader_rank').notNull(), + locatorReaderType: text('locator_reader_type').notNull(), + locatorPage: integer('locator_page').notNull(), + locatorSpineIndex: integer('locator_spine_index').notNull(), + locatorSpineHref: text('locator_spine_href').notNull(), + locatorCharOffset: integer('locator_char_offset').notNull(), + locatorLocation: text('locator_location').notNull(), + locatorIdentityKey: text('locator_identity_key').notNull(), textHash: text('text_hash').notNull(), textLength: integer('text_length').notNull().default(0), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.segmentEntryId, table.userId] }), + index('idx_tts_segment_entries_manifest_sort').on( + table.userId, + table.documentId, + table.documentVersion, + table.locatorReaderRank, + table.locatorSpineIndex, + table.locatorCharOffset, + table.locatorSpineHref, + table.locatorPage, + table.locatorLocation, + table.segmentIndex, + table.locatorIdentityKey, + ), + index('idx_tts_segment_entries_manifest_group').on( + table.userId, + table.documentId, + table.documentVersion, + table.segmentIndex, + table.locatorIdentityKey, + ), + index('idx_tts_segment_entries_scope').on( + table.userId, + table.documentId, + table.documentVersion, + ), +]); + +export const ttsSegmentVariants = sqliteTable('tts_segment_variants', { + segmentId: text('segment_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + segmentEntryId: text('segment_entry_id').notNull(), + settingsHash: text('settings_hash').notNull(), + settingsJson: text('settings_json').notNull(), audioKey: text('audio_key'), audioFormat: text('audio_format').notNull().default('mp3'), durationMs: integer('duration_ms'), @@ -132,6 +174,11 @@ export const ttsSegments = sqliteTable('tts_segments', { updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.segmentId, table.userId] }), - index('idx_tts_segments_lookup').on(table.userId, table.documentId, table.documentVersion, table.settingsHash), - index('idx_tts_segments_doc_index').on(table.userId, table.documentId, table.segmentIndex), + foreignKey({ + columns: [table.segmentEntryId, table.userId], + foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId], + }).onDelete('cascade'), + index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt), + index('idx_tts_segment_variants_status').on(table.userId, table.status), + index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash), ]); diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts index e003cef..a9d2a2a 100644 --- a/src/lib/server/tts/segments-audio.ts +++ b/src/lib/server/tts/segments-audio.ts @@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; export type ResolvedSegmentAudio = { @@ -41,15 +41,19 @@ export async function resolveCompletedSegmentAudio( const rows = (await db .select({ - audioKey: ttsSegments.audioKey, - status: ttsSegments.status, + audioKey: ttsSegmentVariants.audioKey, + status: ttsSegmentVariants.status, }) - .from(ttsSegments) + .from(ttsSegmentVariants) + .innerJoin(ttsSegmentEntries, and( + eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), + eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), + )) .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), - eq(ttsSegments.segmentId, segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + eq(ttsSegmentEntries.documentId, documentId), + eq(ttsSegmentEntries.documentVersion, scope.documentVersion), + eq(ttsSegmentVariants.segmentId, segmentId), ))) as Array<{ audioKey: string | null; status: string }>; const row = rows[0]; diff --git a/src/lib/server/tts/segments-manifest.ts b/src/lib/server/tts/segments-manifest.ts index 2faf304..f3d0339 100644 --- a/src/lib/server/tts/segments-manifest.ts +++ b/src/lib/server/tts/segments-manifest.ts @@ -10,6 +10,18 @@ export const DEFAULT_PAGE_SIZE = 150; export const MIN_PAGE_SIZE = 25; export const MAX_PAGE_SIZE = 500; +export type TTSSegmentManifestCursor = { + locatorReaderRank: number; + locatorSpineIndex: number; + locatorCharOffset: number; + locatorSpineHref: string; + locatorPage: number; + locatorLocation: string; + segmentIndex: number; + locatorIdentityKey: string; + segmentEntryId: string; +}; + function statusRank(status: TTSSegmentVariant['status']): number { if (status === 'completed') return 3; if (status === 'pending') return 2; @@ -45,24 +57,49 @@ export function compareManifestSegments( return a.groupKey.localeCompare(b.groupKey); } -export function decodeManifestCursor(cursor: string | null): string | null { +export function decodeManifestCursor(cursor: string | null): TTSSegmentManifestCursor | null { if (!cursor) return null; try { const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); if (!decoded) return null; - // Reject malformed input that Node's base64url decoder may parse into gibberish. - const normalizedInput = cursor.replace(/=+$/, ''); - if (encodeManifestCursor(decoded) !== normalizedInput) { + let parsed: unknown = null; + try { + parsed = JSON.parse(decoded); + } catch { return null; } - return decoded; + if (!parsed || typeof parsed !== 'object') return null; + const rec = parsed as Record; + const next: TTSSegmentManifestCursor = { + locatorReaderRank: Number(rec.locatorReaderRank), + locatorSpineIndex: Number(rec.locatorSpineIndex), + locatorCharOffset: Number(rec.locatorCharOffset), + locatorSpineHref: typeof rec.locatorSpineHref === 'string' ? rec.locatorSpineHref : '', + locatorPage: Number(rec.locatorPage), + locatorLocation: typeof rec.locatorLocation === 'string' ? rec.locatorLocation : '', + segmentIndex: Number(rec.segmentIndex), + locatorIdentityKey: typeof rec.locatorIdentityKey === 'string' ? rec.locatorIdentityKey : '', + segmentEntryId: typeof rec.segmentEntryId === 'string' ? rec.segmentEntryId : '', + }; + if (!Number.isFinite(next.locatorReaderRank)) return null; + if (!Number.isFinite(next.locatorSpineIndex)) return null; + if (!Number.isFinite(next.locatorCharOffset)) return null; + if (!Number.isFinite(next.locatorPage)) return null; + if (!Number.isFinite(next.segmentIndex)) return null; + if (!next.locatorIdentityKey) return null; + if (!next.segmentEntryId) return null; + const normalizedInput = cursor.replace(/=+$/, ''); + if (encodeManifestCursor(next) !== normalizedInput) { + return null; + } + return next; } catch { return null; } } -export function encodeManifestCursor(value: string): string { - return Buffer.from(value, 'utf8').toString('base64url'); +export function encodeManifestCursor(value: TTSSegmentManifestCursor): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); } export function parseManifestPageSize(value: string | null): number { diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 1c291c2..35ad21b 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; +import { locatorIdentityKey } from '@/lib/shared/tts-locator'; import { ffprobeAudio } from '@/lib/server/audiobooks/chapters'; import type { TTSSegmentLocator, @@ -54,6 +55,17 @@ export function normalizeSegmentText(text: string): string { return preprocessSentenceForAudio(text || '').trim(); } +export type TTSSegmentLocatorProjection = { + locatorReaderRank: number; + locatorReaderType: string; + locatorPage: number; + locatorSpineIndex: number; + locatorSpineHref: string; + locatorCharOffset: number; + locatorLocation: string; + locatorIdentityKey: string; +}; + /** * Validate and shape a locator for persistence. EPUB locators MUST carry the * stable spine coordinates (`spineHref`, `spineIndex`, `charOffset`) — the @@ -104,27 +116,51 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg return null; } +export function projectSegmentLocator(locator: TTSSegmentLocator): TTSSegmentLocatorProjection { + if (locator.readerType === 'epub') { + return { + locatorReaderRank: 0, + locatorReaderType: 'epub', + locatorPage: -1, + locatorSpineIndex: typeof locator.spineIndex === 'number' ? locator.spineIndex : -1, + locatorSpineHref: typeof locator.spineHref === 'string' ? locator.spineHref : '', + locatorCharOffset: typeof locator.charOffset === 'number' ? locator.charOffset : -1, + locatorLocation: '', + locatorIdentityKey: locatorIdentityKey(locator), + }; + } + if (locator.readerType === 'pdf') { + return { + locatorReaderRank: 1, + locatorReaderType: 'pdf', + locatorPage: typeof locator.page === 'number' ? Math.floor(locator.page) : -1, + locatorSpineIndex: -1, + locatorSpineHref: '', + locatorCharOffset: -1, + locatorLocation: '', + locatorIdentityKey: locatorIdentityKey(locator), + }; + } + if (locator.readerType === 'html') { + return { + locatorReaderRank: 2, + locatorReaderType: 'html', + locatorPage: -1, + locatorSpineIndex: -1, + locatorSpineHref: '', + locatorCharOffset: -1, + locatorLocation: typeof locator.location === 'string' ? locator.location : '', + locatorIdentityKey: locatorIdentityKey(locator), + }; + } + throw new Error(`Unsupported segment locator readerType for projection: ${String(locator.readerType)}`); +} + export function locatorFingerprint(locator: TTSSegmentLocator | null): string { if (!locator) return ''; return createHash('sha256').update(stableStringify(locator)).digest('hex'); } -export function canonicalLocatorJson(locator: TTSSegmentLocator | null | undefined): string | null { - if (!locator) return null; - return stableStringify(locator); -} - -export function canonicalizeLocatorJsonString(json: string | null | undefined): string | null { - if (!json) return null; - try { - const parsed = JSON.parse(json); - if (parsed === null || typeof parsed !== 'object') return null; - return stableStringify(parsed); - } catch { - return null; - } -} - export function buildTtsSegmentId(input: { documentId: string; documentVersion: number; @@ -146,6 +182,25 @@ export function buildTtsSegmentId(input: { return createHash('sha256').update(canonical).digest('hex'); } +export function buildTtsSegmentEntryId(input: { + documentId: string; + documentVersion: number; + segmentIndex: number; + segmentKey?: string | null; + locatorIdentityKey: string; + textHash: string; +}): string { + const canonical = stableStringify({ + d: input.documentId, + v: input.documentVersion, + i: input.segmentIndex, + k: input.segmentKey ? input.segmentKey : null, + l: input.locatorIdentityKey, + t: input.textHash, + }); + return createHash('sha256').update(canonical).digest('hex'); +} + export function buildTtsSegmentTextHash(text: string, secret: string): string { return createHmac('sha256', secret).update(text).digest('hex'); } @@ -160,7 +215,7 @@ export function buildTtsSegmentAudioKey(input: { segmentId: string; }): string { const nsSegment = input.namespace ? `ns/${input.namespace}/` : ''; - return `${input.storagePrefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`; + return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`; } export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise { diff --git a/src/lib/server/user/data-cleanup.ts b/src/lib/server/user/data-cleanup.ts index 9b74c67..29e39e1 100644 --- a/src/lib/server/user/data-cleanup.ts +++ b/src/lib/server/user/data-cleanup.ts @@ -92,8 +92,10 @@ export async function deleteUserStorageData( try { const cfg = getS3Config(); const nsSegment = namespace ? `ns/${namespace}/` : ''; - const ttsPrefix = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`; - segmentsDeleted = await deleteTtsSegmentPrefix(ttsPrefix); + const ttsPrefixV1 = `${cfg.prefix}/tts_segments_v1/${nsSegment}users/${encodeURIComponent(userId)}/`; + const ttsPrefixV2 = `${cfg.prefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(userId)}/`; + segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1); + segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); } catch (error) { console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error); } diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index f77bdd5..1115dc0 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -76,7 +76,8 @@ export default async function globalTeardown(): Promise { const config = getS3Config(); const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`; const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`; - const ttsSegmentsNsRootPrefix = `${config.prefix}/tts_segments_v1/ns/`; + const ttsSegmentsNsRootPrefixV1 = `${config.prefix}/tts_segments_v1/ns/`; + const ttsSegmentsNsRootPrefixV2 = `${config.prefix}/tts_segments_v2/ns/`; // Remove SQL audiobook rows for namespaced objects (covers auth claim flows too). const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix); @@ -105,5 +106,6 @@ export default async function globalTeardown(): Promise { await deleteDocumentPrefix(docsNsRootPrefix); await deleteAudiobookPrefix(audiobooksNsRootPrefix); - await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefix); + await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1); + await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2); } diff --git a/tests/unit/tts-segments-manifest.spec.ts b/tests/unit/tts-segments-manifest.spec.ts index cc20f2b..513ca5c 100644 --- a/tests/unit/tts-segments-manifest.spec.ts +++ b/tests/unit/tts-segments-manifest.spec.ts @@ -157,10 +157,22 @@ test.describe('tts segments manifest helpers', () => { }); test('encodes and decodes cursors', () => { - const raw = '3|p:2|l:epubcfi(/6/2)|r:epub'; + const raw = { + locatorReaderRank: 0, + locatorSpineIndex: 2, + locatorCharOffset: 128, + locatorSpineHref: 'OEBPS/ch02.xhtml', + locatorPage: -1, + locatorLocation: '', + segmentIndex: 4, + locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128', + segmentEntryId: 'entry-4', + }; const encoded = encodeManifestCursor(raw); - expect(decodeManifestCursor(encoded)).toBe(raw); + expect(decodeManifestCursor(encoded)).toEqual(raw); expect(decodeManifestCursor('not-base64')).toBeNull(); + const malformed = Buffer.from(JSON.stringify({ bad: true }), 'utf8').toString('base64url'); + expect(decodeManifestCursor(malformed)).toBeNull(); }); test('clamps page size bounds', () => { diff --git a/tests/unit/tts-segments.spec.ts b/tests/unit/tts-segments.spec.ts index f2175ab..f067104 100644 --- a/tests/unit/tts-segments.spec.ts +++ b/tests/unit/tts-segments.spec.ts @@ -1,12 +1,14 @@ import { expect, test } from '@playwright/test'; import { buildProportionalAlignment, + buildTtsSegmentEntryId, buildTtsSegmentId, buildTtsSegmentSettingsHash, buildTtsSegmentTextHash, locatorFingerprint, normalizeLocator, normalizeSegmentText, + projectSegmentLocator, } from '../../src/lib/server/tts/segments'; test.describe('tts segment helpers', () => { @@ -57,6 +59,35 @@ test.describe('tts segment helpers', () => { expect(id1).not.toBe(id3); }); + test('builds deterministic segment entry id independent of settings', () => { + const entry1 = buildTtsSegmentEntryId({ + documentId: 'doc', + documentVersion: 1, + segmentIndex: 2, + segmentKey: 'doc:v1:segment-a', + locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128', + textHash: 'abc123', + }); + const entry2 = buildTtsSegmentEntryId({ + documentId: 'doc', + documentVersion: 1, + segmentIndex: 2, + segmentKey: 'doc:v1:segment-a', + locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128', + textHash: 'abc123', + }); + const entry3 = buildTtsSegmentEntryId({ + documentId: 'doc', + documentVersion: 1, + segmentIndex: 2, + segmentKey: 'doc:v1:segment-b', + locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128', + textHash: 'abc123', + }); + expect(entry1).toBe(entry2); + expect(entry1).not.toBe(entry3); + }); + test('canonical segment key makes id independent of locator and index', () => { const id1 = buildTtsSegmentId({ documentId: 'doc', @@ -97,8 +128,15 @@ test.describe('tts segment helpers', () => { test('normalizes PDF locators and creates fingerprints', () => { const locator = normalizeLocator({ readerType: 'pdf', page: 2.9 }); + if (!locator) throw new Error('expected normalized pdf locator'); expect(locator).toEqual({ readerType: 'pdf', page: 2 }); expect(locatorFingerprint(locator)).toHaveLength(64); + expect(projectSegmentLocator(locator)).toMatchObject({ + locatorReaderRank: 1, + locatorReaderType: 'pdf', + locatorPage: 2, + locatorIdentityKey: 'pdf:2', + }); }); test('normalizes stable EPUB locators and rejects legacy CFI-only drafts', () => { @@ -110,6 +148,7 @@ test.describe('tts segment helpers', () => { charOffset: 128.4, cfi: ' epubcfi(/6/4!/4:0) ', }); + if (!stable) throw new Error('expected normalized epub locator'); expect(stable).toEqual({ readerType: 'epub', spineHref: 'OEBPS/ch02.xhtml', @@ -118,6 +157,14 @@ test.describe('tts segment helpers', () => { cfi: 'epubcfi(/6/4!/4:0)', }); expect(locatorFingerprint(stable)).toHaveLength(64); + expect(projectSegmentLocator(stable)).toMatchObject({ + locatorReaderRank: 0, + locatorReaderType: 'epub', + locatorSpineIndex: 2, + locatorCharOffset: 128, + locatorSpineHref: 'OEBPS/ch02.xhtml', + locatorIdentityKey: 'epub:2:OEBPS/ch02.xhtml:128', + }); // Legacy/draft EPUB shape (CFI in `location` but no spine coords) is // rejected so we never persist viewport-dependent locators. @@ -125,6 +172,15 @@ test.describe('tts segment helpers', () => { expect(legacy).toBeNull(); }); + test('projects html locators into stable manifest sort fields', () => { + expect(projectSegmentLocator({ readerType: 'html', location: '#intro' })).toMatchObject({ + locatorReaderRank: 2, + locatorReaderType: 'html', + locatorLocation: '#intro', + locatorIdentityKey: 'html:#intro', + }); + }); + test('builds proportional alignment preserving order', () => { const alignment = buildProportionalAlignment({ sentence: normalizeSegmentText('Hello world again'),