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'),