diff --git a/drizzle/postgres/0000_military_nemesis.sql b/drizzle/postgres/0000_lucky_zarek.sql similarity index 83% rename from drizzle/postgres/0000_military_nemesis.sql rename to drizzle/postgres/0000_lucky_zarek.sql index f40f596..2c24b6d 100644 --- a/drizzle/postgres/0000_military_nemesis.sql +++ b/drizzle/postgres/0000_lucky_zarek.sql @@ -15,25 +15,27 @@ CREATE TABLE "account" ( ); --> statement-breakpoint CREATE TABLE "audiobook_chapters" ( - "id" text PRIMARY KEY NOT NULL, + "id" text NOT NULL, "book_id" text NOT NULL, - "user_id" text, + "user_id" text NOT NULL, "chapter_index" integer NOT NULL, "title" text NOT NULL, "duration" real DEFAULT 0, "file_path" text NOT NULL, - "format" text NOT NULL + "format" text NOT NULL, + CONSTRAINT "audiobook_chapters_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint CREATE TABLE "audiobooks" ( - "id" text PRIMARY KEY NOT NULL, - "user_id" text, + "id" text NOT NULL, + "user_id" text NOT NULL, "title" text NOT NULL, "author" text, "description" text, "cover_path" text, "duration" real DEFAULT 0, - "created_at" timestamp DEFAULT now() + "created_at" timestamp DEFAULT now(), + CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint CREATE TABLE "documents" ( @@ -88,7 +90,6 @@ CREATE TABLE "verification" ( "updated_at" timestamp ); --> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_audiobooks_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."audiobooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); \ No newline at end of file diff --git a/drizzle/postgres/0001_futuristic_harpoon.sql b/drizzle/postgres/0001_futuristic_harpoon.sql deleted file mode 100644 index d6354ee..0000000 --- a/drizzle/postgres/0001_futuristic_harpoon.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk"; ---> statement-breakpoint -ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk"; ---> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index 90b979e..04b9ece 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5ac6552c-824b-4b79-8cb6-f0ec3d121031", + "id": "63a00f5b-69ff-4d95-a48a-9e84cc331445", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -99,7 +99,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -116,7 +116,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, "book_id": { @@ -129,7 +129,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "chapter_index": { "name": "chapter_index", @@ -164,22 +164,16 @@ } }, "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "foreignKeys": {}, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, @@ -192,14 +186,14 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "title": { "name": "title", @@ -242,7 +236,15 @@ }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, @@ -383,7 +385,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -589,4 +591,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/postgres/meta/0001_snapshot.json b/drizzle/postgres/meta/0001_snapshot.json deleted file mode 100644 index adf5b27..0000000 --- a/drizzle/postgres/meta/0001_snapshot.json +++ /dev/null @@ -1,592 +0,0 @@ -{ - "id": "62e82369-d624-4cc1-a482-7ea5fbc7275b", - "prevId": "5ac6552c-824b-4b79-8cb6-f0ec3d121031", - "version": "7", - "dialect": "postgresql", - "tables": { - "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 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "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.audiobook_chapters": { - "name": "audiobook_chapters", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "book_id": { - "name": "book_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "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_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audiobooks": { - "name": "audiobooks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "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": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.documents": { - "name": "documents", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "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": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "documents_id_user_id_pk": { - "name": "documents_id_user_id_pk", - "columns": [ - "id", - "user_id" - ] - } - }, - "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 - }, - "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": {}, - "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 - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "boolean", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "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": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "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.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 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 7b605fa..7b112b7 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "7", - "when": 1769393893675, - "tag": "0000_military_nemesis", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1769404444945, - "tag": "0001_futuristic_harpoon", + "when": 1769641576464, + "tag": "0000_lucky_zarek", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/drizzle/sqlite/0000_hard_mandarin.sql b/drizzle/sqlite/0000_steep_shaman.sql similarity index 91% rename from drizzle/sqlite/0000_hard_mandarin.sql rename to drizzle/sqlite/0000_steep_shaman.sql index fbea2df..190de61 100644 --- a/drizzle/sqlite/0000_hard_mandarin.sql +++ b/drizzle/sqlite/0000_steep_shaman.sql @@ -12,30 +12,31 @@ CREATE TABLE `account` ( `password` text, `created_at` integer NOT NULL, `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `audiobook_chapters` ( - `id` text PRIMARY KEY NOT NULL, + `id` text NOT NULL, `book_id` text NOT NULL, - `user_id` text, + `user_id` text NOT NULL, `chapter_index` integer NOT NULL, `title` text NOT NULL, `duration` real DEFAULT 0, `file_path` text NOT NULL, `format` text NOT NULL, - FOREIGN KEY (`book_id`) REFERENCES `audiobooks`(`id`) ON UPDATE no action ON DELETE cascade + PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint CREATE TABLE `audiobooks` ( - `id` text PRIMARY KEY NOT NULL, - `user_id` text, + `id` text NOT NULL, + `user_id` text NOT NULL, `title` text NOT NULL, `author` text, `description` text, `cover_path` text, `duration` real DEFAULT 0, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint CREATE TABLE `documents` ( @@ -59,7 +60,7 @@ CREATE TABLE `session` ( `ip_address` text, `user_agent` text, `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint diff --git a/drizzle/sqlite/0001_violet_mantis.sql b/drizzle/sqlite/0001_violet_mantis.sql deleted file mode 100644 index 5051a39..0000000 --- a/drizzle/sqlite/0001_violet_mantis.sql +++ /dev/null @@ -1,38 +0,0 @@ -PRAGMA foreign_keys=OFF;--> statement-breakpoint -CREATE TABLE `__new_account` ( - `id` text PRIMARY KEY NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text, - `refresh_token` text, - `id_token` text, - `access_token_expires_at` integer, - `refresh_token_expires_at` integer, - `scope` text, - `password` text, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_account`("id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at") SELECT "id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at" FROM `account`;--> statement-breakpoint -DROP TABLE `account`;--> statement-breakpoint -ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint -PRAGMA foreign_keys=ON;--> statement-breakpoint -CREATE TABLE `__new_session` ( - `id` text PRIMARY KEY NOT NULL, - `expires_at` integer NOT NULL, - `token` text NOT NULL, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL, - `ip_address` text, - `user_agent` text, - `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_session`("id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id") SELECT "id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id" FROM `session`;--> statement-breakpoint -DROP TABLE `session`;--> statement-breakpoint -ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint -CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`); diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index a159691..ab33249 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "f5c06478-a702-4809-ab9b-3a63f11b8b0c", + "id": "af57dff3-a6ec-418e-9fa1-8197d6839e48", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -111,7 +111,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -125,7 +125,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, @@ -140,7 +140,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "chapter_index": { @@ -181,22 +181,16 @@ } }, "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" + "foreignKeys": {}, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "name": "audiobook_chapters_id_user_id_pk" } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -206,7 +200,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, @@ -214,7 +208,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "title": { @@ -264,7 +258,15 @@ }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -423,7 +425,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -618,4 +620,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/0001_snapshot.json b/drizzle/sqlite/meta/0001_snapshot.json deleted file mode 100644 index bb004f5..0000000 --- a/drizzle/sqlite/meta/0001_snapshot.json +++ /dev/null @@ -1,621 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "8d8679bc-1bba-44ce-9f87-271c1eafe068", - "prevId": "f5c06478-a702-4809-ab9b-3a63f11b8b0c", - "tables": { - "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 - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "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": {} - }, - "audiobook_chapters": { - "name": "audiobook_chapters", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "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": false, - "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_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "audiobooks": { - "name": "audiobooks", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "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(strftime('%s','now') as int) * 1000)" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "documents": { - "name": "documents", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": false, - "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(strftime('%s','now') as int) * 1000)" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "documents_id_user_id_pk": { - "columns": [ - "id", - "user_id" - ], - "name": "documents_id_user_id_pk" - } - }, - "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 - }, - "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 - } - }, - "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 - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "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(strftime('%s','now') as int) * 1000)" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" - } - }, - "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": {} - }, - "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 - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 5f8b89b..cb28033 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "6", - "when": 1769395269830, - "tag": "0000_hard_mandarin", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1769535439728, - "tag": "0001_violet_mantis", + "when": 1769641566451, + "tag": "0000_steep_shaman", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 877a6a7..c36eec1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,8 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run build && npm run start', + // Disable auth rate limiting for tests to support parallel workers creating sessions + command: 'npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start', url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 8f09cd8..609d352 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,21 +1,43 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; import { readdir, unlink } from 'fs/promises'; -import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join, resolve } from 'path'; +import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, UNCLAIMED_USER_ID, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { findStoredChapterByIndex } from '@/lib/server/audiobook'; import { db } from '@/db'; -import { audiobookChapters } from '@/db/schema'; -import { and, eq } from 'drizzle-orm'; -import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth'; +import { audiobooks, audiobookChapters } from '@/db/schema'; +import { and, eq, or } from 'drizzle-orm'; +import { requireAuthContext } from '@/lib/server/auth'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR. + * When auth is enabled, returns the user-specific directory. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; + + const getTestNamespacePath = (baseDir: string): string => { + if (!raw) return baseDir; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { + return baseDir; + } + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; + } + return resolved; + }; + + if (!authEnabled || !userId) { + return getTestNamespacePath(AUDIOBOOKS_V1_DIR); + } + + const userDir = getUserAudiobookDir(userId); + return getTestNamespacePath(userDir); } export async function GET(request: NextRequest) { @@ -47,12 +69,31 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' }); - if (denied) return denied; + const { userId, authEnabled } = ctxOrRes; + + // Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); if (!chapter || !existsSync(chapter.filePath)) { @@ -128,20 +169,28 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'forbidden' }); - if (denied) return denied; - - // Delete from DB first - if (db) { - await db.delete(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.chapterIndex, chapterIndex)), - ); + // Verify ownership and delete from DB with composite PK + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + + // Delete from DB + await db.delete(audiobookChapters).where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, userId), + eq(audiobookChapters.chapterIndex, chapterIndex) + ), + ); } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; const files = await readdir(intermediateDir).catch(() => []); for (const file of files) { @@ -167,4 +216,3 @@ export async function DELETE(request: NextRequest) { ); } } - diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index c04834c..5abfafe 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -4,32 +4,51 @@ import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/prom import { existsSync, createReadStream } from 'fs'; import { basename, join, resolve } from 'path'; import { randomUUID } from 'crypto'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and, or } from 'drizzle-orm'; import { isAuthEnabled } from '@/lib/server/auth-config'; -import { getAuthContext, requireAuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Apply test namespace to a directory path if present in request headers. + */ +function applyTestNamespace(baseDir: string, request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; + if (!raw) return baseDir; const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { - return AUDIOBOOKS_V1_DIR; + return baseDir; } - const resolved = resolve(AUDIOBOOKS_V1_DIR, safe); - if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) { - return AUDIOBOOKS_V1_DIR; + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; } return resolved; } +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR (possibly with test namespace). + * When auth is enabled, returns the user-specific directory under AUDIOBOOKS_USERS_DIR. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { + // When auth is disabled, use the flat audiobooks_v1 directory + if (!authEnabled || !userId) { + return applyTestNamespace(AUDIOBOOKS_V1_DIR, request); + } + + // When auth is enabled, use user-specific directory + const userDir = getUserAudiobookDir(userId); + return applyTestNamespace(userDir, request); +} + interface ConversionRequest { chapterTitle: string; buffer: TTSAudioBytes; @@ -183,8 +202,10 @@ export async function POST(request: NextRequest) { } // DB Check / Insert Audiobook - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); + if (isAuthEnabled() && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); if (!existingBook) { await db.insert(audiobooks).values({ @@ -192,14 +213,10 @@ export async function POST(request: NextRequest) { userId, title: data.chapterTitle || 'Untitled Audiobook', }); - } else { - if (existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, ctxOrRes.authEnabled), `${bookId}-audiobook`); // Create intermediate directory await mkdir(intermediateDir, { recursive: true }); @@ -346,7 +363,7 @@ export async function POST(request: NextRequest) { await unlink(inputPath).catch(console.error); // Insert Chapter Record (Denormalized) - if (isAuthEnabled() && db) { + if (isAuthEnabled() && db && userId) { await db.insert(audiobookChapters).values({ id: `${bookId}-${chapterIndex}`, bookId, @@ -357,7 +374,7 @@ export async function POST(request: NextRequest) { format, filePath: finalChapterName }).onConflictDoUpdate({ - target: [audiobookChapters.id], + target: [audiobookChapters.id, audiobookChapters.userId], set: { title: data.chapterTitle, duration, format, filePath: finalChapterName } }); } @@ -404,17 +421,33 @@ export async function GET(request: NextRequest) { ); } - const { userId } = await getAuthContext(request); + const ctxOrRes = await requireAuthContext(request); + if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; - // Verify ownership - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); - if (existingBook && existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); // Hide existence + // Check if audiobook exists for user OR is unclaimed (similar to documents) + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); @@ -610,24 +643,29 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; + const { userId, authEnabled } = ctxOrRes; - // Verify ownership - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); + // Delete from DB - with composite PK, we delete by both id and userId + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); - if (existingBook) { - if (existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - await db.delete(audiobooks).where(eq(audiobooks.id, bookId)); - } else { + if (!existingBook) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + + // Delete chapters first (no foreign key constraint with composite PK) + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId)) + ); + + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index ec810d5..3b9f91d 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,20 +1,45 @@ import { NextRequest, NextResponse } from 'next/server'; import { existsSync } from 'fs'; -import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join, resolve } from 'path'; +import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUserAudiobookDir, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { listStoredChapters } from '@/lib/server/audiobook'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; import { readFile } from 'fs/promises'; -import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; +import { db } from '@/db'; +import { audiobooks } from '@/db/schema'; +import { eq, and, or } from 'drizzle-orm'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR. + * When auth is enabled, returns the user-specific directory. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; + + const applyTestNamespace = (baseDir: string): string => { + if (!raw) return baseDir; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { + return baseDir; + } + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; + } + return resolved; + }; + + if (!authEnabled || !userId) { + return applyTestNamespace(AUDIOBOOKS_V1_DIR); + } + + const userDir = getUserAudiobookDir(userId); + return applyTestNamespace(userDir); } const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; @@ -40,9 +65,18 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' }); - if (denied) { + const { userId, authEnabled } = ctxOrRes; + + // Check if audiobook exists for user OR is unclaimed (similar to documents) + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + // Book doesn't exist for this user or unclaimed - return empty state return NextResponse.json({ chapters: [], exists: false, @@ -53,7 +87,16 @@ export async function GET(request: NextRequest) { } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } if (!existsSync(intermediateDir)) { return NextResponse.json({ diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 3aca979..e1e93a3 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,15 +1,97 @@ 'use client'; import { useEffect, useRef, useState, ReactNode } from 'react'; +import type { BetterFetchError } from 'better-auth/react'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type ErrorInfo = { status?: number; message?: string }; + +function isBetterFetchError(input: unknown): input is BetterFetchError { + if (!input || typeof input !== 'object') return false; + const rec = input as Record; + return ( + input instanceof Error && + typeof rec.status === 'number' && + typeof rec.statusText === 'string' && + 'error' in rec + ); +} + +/** + * Normalize different error shapes into a single `{ status, message }`. + * + * Handles: + * - thrown Errors that may include `status` + * - better-auth / better-fetch style endpoint returns: `{ data, error }` + */ +function getErrorInfo(input: unknown): ErrorInfo | null { + if (!input) return null; + + if (isBetterFetchError(input)) { + return { + status: input.status, + message: input.statusText || input.message, + }; + } + + if (input instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const status = typeof (input as any).status === 'number' ? (input as any).status : undefined; + return { status, message: input.message }; + } + + // Handle better-fetch style: { error: { status, message } } OR { error: string } + if (typeof input === 'object') { + const rec = input as Record; + if ('error' in rec && rec.error) { + const err = rec.error; + if (isBetterFetchError(err)) { + return { + status: err.status, + message: err.statusText || err.message, + }; + } + if (typeof err === 'object' && err !== null) { + const e = err as Record; + const status = typeof e.status === 'number' ? e.status : undefined; + const message = typeof e.message === 'string' ? e.message : undefined; + return { status, message }; + } + return { message: typeof err === 'string' ? err : 'Request failed' }; + } + + const status = rec.status; + const message = rec.message; + const out: ErrorInfo = {}; + if (typeof status === 'number') out.status = status; + if (typeof message === 'string') out.message = message; + if (out.status !== undefined || out.message !== undefined) return out; + } + + if (typeof input === 'string') return { message: input }; + + return null; +} + +function isRateLimited(info: ErrorInfo | null): boolean { + if (!info) return false; + if (info.status === 429) return true; + // Fallback for cases where the server didn't return a numeric status. + const msg = info.message || ''; + return /too\s+many\s+requests|rate\s*limit/i.test(msg); +} + export function AuthLoader({ children }: { children: ReactNode }) { const { authEnabled, baseUrl } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); - const { data: session, isPending } = useAuthSession(); + const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [bootstrapError, setBootstrapError] = useState(null); const [retryNonce, setRetryNonce] = useState(0); @@ -43,18 +125,78 @@ export function AuthLoader({ children }: { children: ReactNode }) { try { const client = getAuthClient(baseUrl); - await client.signIn.anonymous(); + + // In Playwright/`next start` we sometimes hit 429s on anonymous sign-in. + // Keep using better-auth client so its session hook updates correctly, + // but add retry/backoff around the call. + const maxAttempts = 6; + const baseDelayMs = 500; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const attemptTag = `[AuthLoader] better-auth signIn.anonymous attempt ${attempt}/${maxAttempts}`; + try { + console.info(attemptTag); + const result = await client.signIn.anonymous(); + const info = getErrorInfo(result); + if (info) { + // better-auth client endpoints often do NOT throw; they return { data, error }. + // Convert that into a thrown error so our retry/backoff logic works. + const e = new Error(info.message || 'Anonymous sign-in failed'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).status = info.status; + console.warn(`${attemptTag} (non-throwing error response)`, info); + throw e; + } + + console.info(`${attemptTag} (success)`, result ? { hasResult: true } : { hasResult: false }); + + // In some environments (notably Playwright against `next start`), + // the session signal does not immediately update after setting the + // session cookie. Force an explicit session refetch. + if (typeof refetchSession === 'function') { + console.info('[AuthLoader] refetching session after anonymous sign-in'); + await refetchSession(); + } + + // Give React a moment to observe the updated session. + await sleep(50); + break; + } catch (err) { + const info = getErrorInfo(err); + console.warn(`${attemptTag} (failed)`, info ?? undefined); + console.warn(err); + + if (isRateLimited(info) && attempt < maxAttempts) { + // better-auth doesn't currently expose Retry-After headers here; + // fall back to exponential backoff. + const delayMs = Math.min(10_000, baseDelayMs * Math.pow(2, attempt - 1)); + console.warn(`${attemptTag} rate-limited; waiting ${delayMs}ms before retry`); + await sleep(delayMs); + continue; + } + + throw err; + } + } + await refreshRateLimit(); } catch (err) { - console.error('Auto-login failed', err); - setBootstrapError('Unable to start an anonymous session.'); + console.error('[AuthLoader] auto-login failed', err); + setBootstrapError('Unable to start an anonymous session (rate limited or network error).'); } finally { setIsAutoLoggingIn(false); } }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, refetchSession, retryNonce]); + + useEffect(() => { + if (!authEnabled) return; + if (sessionError) { + console.warn('[AuthLoader] useSession error', sessionError); + } + }, [authEnabled, sessionError]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fa42b22..fb7c19f 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -100,6 +100,10 @@ interface SetTextOptions { const CONTINUATION_LOOKAHEAD = 600; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; +// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari. +const SILENT_WAV_DATA_URI = + 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + const normalizeLocationKey = (location: TTSLocation) => typeof location === 'number' ? `num:${location}` : `str:${location}`; @@ -367,6 +371,109 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const sentencesRef = useRef([]); const currentIndexRef = useRef(0); + const audioUnlockAttemptRef = useRef(0); + + // Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying + // the desired rate while a sentence is playing. + const rateWatchdogIntervalRef = useRef | null>(null); + + const clearRateWatchdog = useCallback(() => { + if (!rateWatchdogIntervalRef.current) return; + clearInterval(rateWatchdogIntervalRef.current); + rateWatchdogIntervalRef.current = null; + }, []); + + const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => { + if (!howl) return; + + try { + howl.rate(audioSpeed); + } catch { + // ignore + } + + // Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly. + // This helps on browsers that reset playbackRate/defaultPlaybackRate. + try { + const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds; + const node = sounds?.[0]?._node as unknown; + if (node && typeof node === 'object') { + const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number }; + if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed; + if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed; + } + } catch { + // ignore + } + }, [audioSpeed]); + + const startRateWatchdog = useCallback((howl: Howl | null) => { + if (!howl) return; + clearRateWatchdog(); + + // Apply immediately + keep applying while playback is active. + applyPlaybackRateToHowl(howl); + rateWatchdogIntervalRef.current = setInterval(() => { + applyPlaybackRateToHowl(howl); + }, 250); + }, [applyPlaybackRateToHowl, clearRateWatchdog]); + + const unlockPlaybackOnUserGesture = useCallback(() => { + // Best-effort; safe to call multiple times. + audioUnlockAttemptRef.current += 1; + const attempt = audioUnlockAttemptRef.current; + + try { + void audioContext?.resume(); + } catch { + // ignore + } + + try { + const el = new Audio(SILENT_WAV_DATA_URI); + try { + el.setAttribute('playsinline', 'true'); + } catch { + // ignore + } + el.preload = 'auto'; + el.volume = 0; + + const p = el.play(); + if (p && typeof (p as Promise).then === 'function') { + void (p as Promise) + .then(() => { + if (audioUnlockAttemptRef.current !== attempt) return; + try { + el.pause(); + el.currentTime = 0; + } catch { + // ignore + } + }) + .catch(() => { + // ignore + }); + } + } catch { + // ignore + } + }, [audioContext]); + + const isAutoplayBlockedError = useCallback((err: unknown) => { + const msg = (() => { + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object' && err !== null && 'message' in err) { + const maybe = (err as { message?: unknown }).message; + if (typeof maybe === 'string') return maybe; + } + return ''; + })(); + + return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg); + }, []); + useEffect(() => { sentencesRef.current = sentences; }, [sentences]); @@ -395,6 +502,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * @param {boolean} [clearPending=false] - Whether to clear pending requests */ const abortAudio = useCallback((clearPending = false) => { + clearRateWatchdog(); if (activeHowl) { activeHowl.stop(); activeHowl.unload(); @@ -414,7 +522,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pageTurnTimeoutRef.current = null; } setCurrentWordIndex(null); - }, [activeHowl]); + }, [activeHowl, clearRateWatchdog]); /** * Pauses the current audio playback @@ -666,15 +774,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * Toggles the playback state between playing and paused */ const togglePlay = useCallback(() => { - setIsPlaying((prev) => { - if (!prev) { - return true; - } else { - abortAudio(); - return false; - } - }); - }, [abortAudio]); + if (isPlaying) { + abortAudio(); + setIsPlaying(false); + return; + } + + // Ensure audio is unlocked while we're still in the click/tap handler. + unlockPlaybackOnUserGesture(); + setIsPlaying(true); + }, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]); /** @@ -1068,6 +1177,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pool: 1, rate: audioSpeed, onload: function (this: Howl) { + applyPlaybackRateToHowl(this); const estimate = pageTurnEstimateRef.current; if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; if (!visualPageChangeHandlerRef.current) return; @@ -1089,14 +1199,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement visualPageChangeHandlerRef.current?.(currentEstimate.location); }, delayMs); }, - onplay: () => { + onplay: function (this: Howl) { setIsProcessing(false); + startRateWatchdog(this); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'playing'; } }, - onplayerror: function (this: Howl, error) { - console.warn('Howl playback error:', error); + onplayerror: function (this: Howl, soundId, error) { + const actualError = error ?? soundId; + console.warn('Howl playback error:', actualError); + + // Common on iOS/Safari when the actual play() call happens after awaiting TTS. + // Do not skip/advance in this case; just pause and tell the user to tap play again. + if (isAutoplayBlockedError(actualError)) { + setIsProcessing(false); + setActiveHowl(null); + try { + this.unload(); + } catch { + // ignore unload errors + } + setIsPlaying(false); + + toast.error('Playback was blocked by your browser. Tap play again to start.', { + id: 'tts-playback-blocked', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 4000, + }); + return; + } + playErrorAttempts += 1; // Avoid looping for many seconds on Safari: if playback still fails after a single @@ -1106,6 +1242,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setActiveHowl(null); this.unload(); setIsPlaying(false); + + toast.error('Audio playback failed. Skipped sentence and paused.', { + id: 'tts-playback-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 4000, + }); + advance(); return; } @@ -1117,8 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement this.load(); } }, - onloaderror: async function (this: Howl, error) { - console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error); + onloaderror: async function (this: Howl, soundId, error) { + const actualError = error ?? soundId; + console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay @@ -1184,6 +1331,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, onend: function (this: Howl) { + clearRateWatchdog(); this.unload(); setActiveHowl(null); if (pageTurnTimeoutRef.current) { @@ -1195,6 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, onstop: function (this: Howl) { + clearRateWatchdog(); setIsProcessing(false); this.unload(); } @@ -1225,7 +1374,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement advance(); return null; } - }, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]); + }, [ + abortAudio, + isPlaying, + advance, + activeHowl, + processSentence, + audioSpeed, + isAutoplayBlockedError, + applyPlaybackRateToHowl, + startRateWatchdog, + clearRateWatchdog, + ]); const playAudio = useCallback(async () => { const sentence = sentences[currentIndex]; @@ -1251,6 +1411,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]); + // Keep the current playback rate applied to the active Howl. Some browsers (notably + // iOS Safari with HTML5 audio) can reset playbackRate after initial load/play. + useEffect(() => { + if (!activeHowl) return; + applyPlaybackRateToHowl(activeHowl); + if (isPlaying) { + startRateWatchdog(activeHowl); + } + }, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]); + // Place useBackgroundState after playAudio is defined const isBackgrounded = useBackgroundState({ activeHowl, @@ -1408,9 +1578,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const stopAndPlayFromIndex = useCallback((index: number) => { abortAudio(); + // Same autoplay-unlock issue as togglePlay when starting from a fresh load. + unlockPlaybackOnUserGesture(); + setCurrentIndex(index); setIsPlaying(true); - }, [abortAudio]); + }, [abortAudio, unlockPlaybackOnUserGesture]); /** * Sets the speed and restarts the playback diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 276cef4..d5fd7ab 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -14,26 +14,30 @@ export const documents = pgTable('documents', { })); export const audiobooks = pgTable('audiobooks', { - id: text('id').primaryKey(), - userId: text('user_id'), + id: text('id').notNull(), + userId: text('user_id').notNull(), title: text('title').notNull(), author: text('author'), description: text('description'), coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: timestamp('created_at').defaultNow(), -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const audiobookChapters = pgTable('audiobook_chapters', { - id: text('id').primaryKey(), - bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }), - userId: text('user_id'), + id: text('id').notNull(), + bookId: text('book_id').notNull(), + userId: text('user_id').notNull(), chapterIndex: integer('chapter_index').notNull(), title: text('title').notNull(), duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const user = pgTable("user", { id: text("id").primaryKey(), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 1d1a3dc..e369796 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -15,26 +15,30 @@ export const documents = sqliteTable('documents', { })); export const audiobooks = sqliteTable('audiobooks', { - id: text('id').primaryKey(), - userId: text('user_id'), + id: text('id').notNull(), + userId: text('user_id').notNull(), title: text('title').notNull(), author: text('author'), description: text('description'), coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const audiobookChapters = sqliteTable('audiobook_chapters', { - id: text('id').primaryKey(), - bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }), - userId: text('user_id'), + id: text('id').notNull(), + bookId: text('book_id').notNull(), + userId: text('user_id').notNull(), chapterIndex: integer('chapter_index').notNull(), title: text('title').notNull(), duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const user = sqliteTable("user", { id: text("id").primaryKey(), diff --git a/src/hooks/useAuthSession.ts b/src/hooks/useAuthSession.ts index ac6c60e..a817221 100644 --- a/src/hooks/useAuthSession.ts +++ b/src/hooks/useAuthSession.ts @@ -4,6 +4,8 @@ import { useMemo } from 'react'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { getAuthClient } from '@/lib/auth-client'; +type SessionHookResult = ReturnType['useSession']>; + /** * Hook for session that uses the correct baseUrl from context */ @@ -16,7 +18,18 @@ export function useAuthSession() { }, [baseUrl, authEnabled]); if (!client) { - return { data: null, isPending: false, error: null }; + // Keep a stable shape so consumers can always destructure the same fields. + // This avoids union-type issues when auth is disabled. + const empty: SessionHookResult = { + data: null, + isPending: false, + isRefetching: false, + // better-auth types use BetterFetchError | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: null as any, + refetch: async () => {}, + }; + return empty; } return client.useSession(); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 0a16158..8753b47 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,9 +7,7 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled } from "@/lib/server/auth-config"; -import { eq } from 'drizzle-orm'; - - +import { transferUserAudiobooks } from "@/lib/server/claim-data"; import * as schema from "@/db/schema"; // Import the dynamic schema @@ -37,6 +35,11 @@ const createAuth = () => betterAuth({ console.log("Password reset requested for:", data.user.email); }, }, + rateLimit: { + // Disable rate limiting when running tests to support parallel test workers + // In production, better-auth's default rate limiting applies + enabled: process.env.DISABLE_AUTH_RATE_LIMIT !== 'true', + }, socialProviders: { ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && { github: { @@ -72,6 +75,17 @@ const createAuth = () => betterAuth({ console.error("Error transferring rate limit data during account linking:", error); // Don't throw here to prevent blocking the account linking process } + + // Transfer audiobooks from anonymous user to new authenticated user + try { + const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring audiobooks during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } } catch (error) { console.error("Error in onLinkAccount callback:", error); // Don't throw here to prevent blocking the account linking process @@ -128,32 +142,4 @@ export async function requireAuthContext( } return ctx; -} - -export type AudiobookAccessMode = 'forbidden' | 'notFound'; - -export async function requireAudiobookOwned( - bookId: string, - userId: string, - options?: { onDenied?: AudiobookAccessMode }, -): Promise { - if (!isAuthEnabled() || !db) return null; - - const [existingBook] = await db - .select({ userId: schema.audiobooks.userId }) - .from(schema.audiobooks) - .where(eq(schema.audiobooks.id, bookId)); - - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - if (existingBook.userId && existingBook.userId !== userId) { - if (options?.onDenied === 'notFound') { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - return null; } \ No newline at end of file diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 4810833..2620c48 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,39 +1,45 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, isNull, count } from 'drizzle-orm'; +import { eq, and, count } from 'drizzle-orm'; import fs from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; -import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore'; +import { + DOCUMENTS_V1_DIR, + AUDIOBOOKS_V1_DIR, + UNCLAIMED_USER_ID, + getUnclaimedAudiobookDir, + getUserAudiobookDir, + moveAudiobookToUser, + listUserAudiobookIds +} from './docstore'; import { listStoredChapters } from './audiobook'; import { isAuthEnabled } from '@/lib/server/auth-config'; -// Helper to check if a file is already indexed +// Helper to check if a document is already indexed async function isDocumentIndexed(id: string) { - if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently? - // Actually if no DB, we don't index into DB. So returning false is fine, - // but scanAndPopulateDB should probably return early if no DB. - + if (!isAuthEnabled() || !db) return false; const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); return result.length > 0; } -async function isAudiobookIndexed(id: string) { +// Helper to check if an audiobook is indexed for a specific user (composite PK) +async function isAudiobookIndexedForUser(id: string, userId: string) { if (!isAuthEnabled() || !db) return false; - const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id)); + const result = await db.select({ id: audiobooks.id }).from(audiobooks).where( + and(eq(audiobooks.id, id), eq(audiobooks.userId, userId)) + ); return result.length > 0; } -const UNCLAIMED_ID = 'unclaimed'; - /** * Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed') */ export async function getUnclaimedCounts() { if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 }; - const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID)); - const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID)); + const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID)); + const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); return { documents: docCount?.count ?? 0, @@ -41,6 +47,41 @@ export async function getUnclaimedCounts() { }; } +/** + * Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to the unclaimed user folder. + * This handles the case where audiobooks were created before the per-user storage refactor. + */ +async function migrateLegacyAudiobooksToUnclaimed(): Promise { + if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; + + const unclaimedDir = getUnclaimedAudiobookDir(); + await fs.mkdir(unclaimedDir, { recursive: true }); + + const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + let migrated = 0; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + const targetDir = path.join(unclaimedDir, entry.name); + + // Skip if already exists in unclaimed + if (existsSync(targetDir)) continue; + + try { + await fs.rename(sourceDir, targetDir); + migrated++; + console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`); + } catch (err) { + console.error(`Error migrating legacy audiobook ${entry.name}:`, err); + } + } + + return migrated; +} + export async function scanAndPopulateDB() { if (!isAuthEnabled() || !db) { console.log('Skipping DB population (Auth/DB disabled)'); @@ -49,19 +90,10 @@ export async function scanAndPopulateDB() { console.log('Scanning file system for un-indexed content...'); - // 0. Fix legacy NULL userIds - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId)); - } catch (err) { - console.error('Error migrating legacy NULL userIds:', err); - } + // 0. Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to unclaimed folder + await migrateLegacyAudiobooksToUnclaimed(); - // 1. Scan Documents + // 1. Scan Documents (unchanged - documents use shared storage) if (existsSync(DOCUMENTS_V1_DIR)) { const files = await fs.readdir(DOCUMENTS_V1_DIR); for (const file of files) { @@ -86,7 +118,7 @@ export async function scanAndPopulateDB() { await db.insert(documents).values({ id, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, name, type: ext, size: stats.size, @@ -97,17 +129,18 @@ export async function scanAndPopulateDB() { } } - // 2. Scan Audiobooks - if (existsSync(AUDIOBOOKS_V1_DIR)) { - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + // 2. Scan Audiobooks from unclaimed folder + const unclaimedDir = getUnclaimedAudiobookDir(); + if (existsSync(unclaimedDir)) { + const entries = await fs.readdir(unclaimedDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (!entry.name.endsWith('-audiobook')) continue; const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexed(bookId)) continue; + if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name); + const dirPath = path.join(unclaimedDir, entry.name); let title = `Unknown Title`; @@ -126,7 +159,7 @@ export async function scanAndPopulateDB() { await db.insert(audiobooks).values({ id: bookId, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, title: title, duration: totalDuration, }); @@ -136,13 +169,13 @@ export async function scanAndPopulateDB() { await db.insert(audiobookChapters).values({ id: `${bookId}-${chapter.index}`, bookId: bookId, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, chapterIndex: chapter.index, title: chapter.title, duration: chapter.durationSec || 0, filePath: chapter.filePath, format: chapter.format - }) + }); } } } @@ -154,25 +187,134 @@ export async function scanAndPopulateDB() { export async function claimAnonymousData(userId: string) { if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 }; - // Update Documents + // Get list of unclaimed audiobook IDs before updating DB + const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID); + + // Update Documents - documents use shared storage, only DB update needed const docResult = await db.update(documents) .set({ userId }) - .where(eq(documents.userId, UNCLAIMED_ID)) - .returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes + .where(eq(documents.userId, UNCLAIMED_USER_ID)) + .returning({ id: documents.id }); - // Update Audiobooks - const bookResult = await db.update(audiobooks) - .set({ userId }) - .where(eq(audiobooks.userId, UNCLAIMED_ID)) - .returning({ id: audiobooks.id }); + // For audiobooks, we need to: + // 1. Move the physical folders from unclaimed to user's folder + // 2. Update the DB records - // Update Chapters (denormalized userId) - await db.update(audiobookChapters) - .set({ userId }) - .where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join + let audiobooksClaimedCount = 0; + const userDir = getUserAudiobookDir(userId); + await fs.mkdir(userDir, { recursive: true }); + + for (const bookId of unclaimedBookIds) { + try { + // Move the audiobook folder + const moved = await moveAudiobookToUser(bookId, UNCLAIMED_USER_ID, userId); + if (moved) { + // Update DB - delete old record and insert new one (composite PK requires this) + const [oldRecord] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ); + + if (oldRecord) { + // Get chapters + const oldChapters = await db.select().from(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) + ); + + // Delete old records + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) + ); + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ); + + // Insert new records with new userId + await db.insert(audiobooks).values({ + ...oldRecord, + userId, + }); + + for (const chapter of oldChapters) { + await db.insert(audiobookChapters).values({ + ...chapter, + userId, + }); + } + + audiobooksClaimedCount++; + console.log(`Claimed audiobook: ${bookId}`); + } + } + } catch (err) { + console.error(`Error claiming audiobook ${bookId}:`, err); + } + } return { documents: docResult.length, - audiobooks: bookResult.length + audiobooks: audiobooksClaimedCount }; } + +/** + * Transfer audiobooks from one user to another. + * Used when an anonymous user creates a real account. + * @returns number of audiobooks transferred + */ +export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !db || !fromUserId || !toUserId) return 0; + + const bookIds = await listUserAudiobookIds(fromUserId); + let transferred = 0; + + const toUserDir = getUserAudiobookDir(toUserId); + await fs.mkdir(toUserDir, { recursive: true }); + + for (const bookId of bookIds) { + try { + // Move the audiobook folder + const moved = await moveAudiobookToUser(bookId, fromUserId, toUserId); + if (moved) { + // Update DB - delete old record and insert new one (composite PK) + const [oldRecord] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) + ); + + if (oldRecord) { + // Get chapters + const oldChapters = await db.select().from(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) + ); + + // Delete old records + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) + ); + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) + ); + + // Insert new records with new userId + await db.insert(audiobooks).values({ + ...oldRecord, + userId: toUserId, + }); + + for (const chapter of oldChapters) { + await db.insert(audiobookChapters).values({ + ...chapter, + userId: toUserId, + }); + } + + transferred++; + console.log(`Transferred audiobook ${bookId} from ${fromUserId} to ${toUserId}`); + } + } + } catch (err) { + console.error(`Error transferring audiobook ${bookId}:`, err); + } + } + + return transferred; +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 340116f..07ebc4b 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -8,6 +8,101 @@ import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ff export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); +export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users'); + +export const UNCLAIMED_USER_ID = 'unclaimed'; + +/** + * Get the audiobook directory for a specific user when auth is enabled. + * Returns path like: docstore/audiobooks_users/{userId} + */ +export function getUserAudiobookDir(userId: string): string { + // Sanitize userId to prevent path traversal + const safeUserId = userId.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeUserId || safeUserId === '.' || safeUserId === '..' || safeUserId.includes('..')) { + throw new Error('Invalid userId for audiobook directory'); + } + return path.join(AUDIOBOOKS_USERS_DIR, safeUserId); +} + +/** + * Get the unclaimed audiobooks directory for pre-auth content. + * Returns path like: docstore/audiobooks_users/unclaimed + */ +export function getUnclaimedAudiobookDir(): string { + return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID); +} + +/** + * Get the full path to a specific audiobook directory. + * - When auth is disabled: docstore/audiobooks_v1/{bookId}-audiobook + * - When auth is enabled: docstore/audiobooks_users/{userId}/{bookId}-audiobook + */ +export function getAudiobookPath(bookId: string, userId: string | null, authEnabled: boolean): string { + if (!authEnabled || !userId) { + return path.join(AUDIOBOOKS_V1_DIR, `${bookId}-audiobook`); + } + return path.join(getUserAudiobookDir(userId), `${bookId}-audiobook`); +} + +/** + * Move an audiobook folder from one user's directory to another. + * Used for claiming unclaimed audiobooks or transferring on account linking. + * @returns true if moved successfully, false if source doesn't exist + */ +export async function moveAudiobookToUser( + bookId: string, + fromUserId: string, + toUserId: string +): Promise { + const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`); + const targetUserDir = getUserAudiobookDir(toUserId); + const targetDir = path.join(targetUserDir, `${bookId}-audiobook`); + + if (!existsSync(sourceDir)) { + return false; + } + + // Ensure target user directory exists + await mkdir(targetUserDir, { recursive: true }); + + // If target already exists, we need to merge or skip + if (existsSync(targetDir)) { + // Target exists - merge contents (move files that don't exist in target) + const result = await mergeDirectoryContents(sourceDir, targetDir); + // Try to remove source if empty + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir, { recursive: true, force: true }); + } + } catch { /* ignore */ } + return result.moved > 0 || result.skipped > 0; + } + + // Simple rename/move + await rename(sourceDir, targetDir); + return true; +} + +/** + * List all audiobook IDs in a user's directory. + */ +export async function listUserAudiobookIds(userId: string): Promise { + const userDir = getUserAudiobookDir(userId); + if (!existsSync(userDir)) return []; + + const entries = await readdir(userDir, { withFileTypes: true }); + const bookIds: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + bookIds.push(entry.name.replace('-audiobook', '')); + } + + return bookIds; +} const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); @@ -341,6 +436,8 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro export async function ensureAudiobooksV1Ready(): Promise { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); + // Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios + await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true }); const state = await loadMigrationState(); const legacyDirsPresent = await hasLegacyAudiobookDirs(); diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index ff738ca..62c7999 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -17,6 +17,10 @@ export const RATE_LIMITS = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const safeDb = () => db as any; +type UserTtsCharsInsert = typeof userTtsChars.$inferInsert; +type UserTtsCharsDateValue = UserTtsCharsInsert['date']; +type UserTtsCharsUpdatedAtValue = UserTtsCharsInsert['updatedAt']; + export interface RateLimitResult { allowed: boolean; @@ -99,6 +103,14 @@ function getRowsAffected(result: unknown): number { export class RateLimiter { constructor() { } + private isPostgres(): boolean { + return Boolean(process.env.POSTGRES_URL); + } + + private getUpdatedAtValue(): Date | number { + return this.isPostgres() ? new Date() : Date.now(); + } + /** * Check if a user can use TTS and increment their char count if allowed */ @@ -114,6 +126,7 @@ export class RateLimiter { } const today = new Date().toISOString().split('T')[0]; + const dateValue = today as unknown as UserTtsCharsDateValue; const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED; const buckets: Bucket[] = [{ key: user.id, limit: userLimit }]; @@ -134,6 +147,13 @@ export class RateLimiter { try { if (!db) throw new Error("DB not initialized"); + + const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; + + // Use a DB transaction to avoid partial increments across buckets and to avoid + // non-transactional "rollback" logic that can corrupt counts under concurrency. + // Note: We intentionally allow a request to push a bucket over its limit; we only + // block when the bucket was already exhausted before this request starts updating. // eslint-disable-next-line @typescript-eslint/no-explicit-any return await safeDb().transaction(async (tx: any) => { // Ensure records exist for each bucket @@ -141,31 +161,30 @@ export class RateLimiter { await tx.insert(userTtsChars) .values({ userId: bucket.key, - date: today as string, + date: dateValue, charCount: 0, }) .onConflictDoUpdate({ target: [userTtsChars.userId, userTtsChars.date], - set: { updatedAt: new Date() }, + set: { updatedAt }, }); } - // Attempt to increment each bucket + // Attempt to increment each bucket. The `lt(..., limit)` guard blocks requests + // that start after the bucket is already exhausted, while still allowing a + // request to push the count over the limit. for (const bucket of buckets) { const updateResult = await tx.update(userTtsChars) .set({ charCount: sql`${userTtsChars.charCount} + ${charCount}`, - updatedAt: new Date() + updatedAt, }) .where(and( eq(userTtsChars.userId, bucket.key), - eq(userTtsChars.date, today as string), + eq(userTtsChars.date, dateValue), lt(userTtsChars.charCount, bucket.limit) )); - // If any bucket is already at/over its limit, reject the request (and roll back all increments). - // Note: we intentionally allow a request to push a bucket over its limit; we only block when the - // bucket was already exhausted before this request started. if (getRowsAffected(updateResult) <= 0) { throw new RateLimitExceeded(); } @@ -176,7 +195,7 @@ export class RateLimiter { for (const bucket of buckets) { const result = await tx.select({ currentCount: userTtsChars.charCount }) .from(userTtsChars) - .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today))); + .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, dateValue))); const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0; bucketResults.push({ currentCount, limit: bucket.limit }); @@ -272,38 +291,36 @@ export class RateLimiter { if (!db) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await safeDb().transaction(async (tx: any) => { - const today = new Date().toISOString().split('T')[0]; + const today = new Date().toISOString().split('T')[0]; + const dateValue = today as unknown as UserTtsCharsDateValue; + const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; - // Get anonymous user's current count - const anonymousResult = await tx.select({ charCount: userTtsChars.charCount }) - .from(userTtsChars) - .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today))); + const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount }) + .from(userTtsChars) + .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); - if (anonymousResult.length > 0) { - const anonymousCount = Number(anonymousResult[0].charCount); + if (anonymousResult.length === 0) return; - const existingAuth = await tx.select({ charCount: userTtsChars.charCount }) - .from(userTtsChars) - .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today))); + const anonymousCount = Number(anonymousResult[0].charCount); - if (existingAuth.length === 0) { - await tx.insert(userTtsChars).values({ userId: authenticatedUserId, date: today, charCount: anonymousCount }); - } else { - const existingCount = Number(existingAuth[0].charCount); - if (anonymousCount > existingCount) { - await tx.update(userTtsChars) - .set({ charCount: anonymousCount, updatedAt: new Date() }) - .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today))); - } - } + const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount }) + .from(userTtsChars) + .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue))); - // Remove anonymous user's record - await tx.delete(userTtsChars) - .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today))); + if (existingAuth.length === 0) { + await safeDb().insert(userTtsChars) + .values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount }); + } else { + const existingCount = Number(existingAuth[0].charCount); + if (anonymousCount > existingCount) { + await safeDb().update(userTtsChars) + .set({ charCount: anonymousCount, updatedAt }) + .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue))); } - }); + } + + await safeDb().delete(userTtsChars) + .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); } /** @@ -313,11 +330,11 @@ export class RateLimiter { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const cutoffDateStr = cutoffDate.toISOString().split('T')[0]; + const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue; if (!db) return; // Assuming string comparison works for YYYY-MM-DD - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateStr as any)); + await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue)); } private getResetTime(): Date { diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 2d3d727..1e297ec 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -46,11 +46,12 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) { page.waitForEvent('download', { timeout: timeoutMs }), fullDownloadButton.click(), ]); - const downloadedPath = await download.path(); - expect(downloadedPath).toBeTruthy(); - const stats = fs.statSync(downloadedPath!); + const tempFilePath = `./tmp_download_${Date.now()}.mp3`; + await download.saveAs(tempFilePath); + expect(fs.existsSync(tempFilePath)).toBeTruthy(); + const stats = fs.statSync(tempFilePath); expect(stats.size).toBeGreaterThan(0); - return downloadedPath!; + return tempFilePath; } async function getAudioDurationSeconds(filePath: string) { @@ -73,6 +74,40 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +/** + * Poll the backend until the chapter count stops changing for `stableMs` milliseconds. + * This helps avoid race conditions where in-flight TTS requests complete after cancellation. + */ +async function waitForStableChapterCount( + page: Page, + bookId: string, + { stableMs = 2000, timeoutMs = 30000 } = {} +): Promise<{ count: number; json: ReturnType extends Promise ? T : never }> { + const startTime = Date.now(); + let lastCount = -1; + let lastStableTime = Date.now(); + let lastJson: Awaited> | null = null; + + while (Date.now() - startTime < timeoutMs) { + const json = await expectChaptersBackendState(page, bookId); + const currentCount = json.chapters?.length ?? 0; + lastJson = json; + + if (currentCount !== lastCount) { + lastCount = currentCount; + lastStableTime = Date.now(); + } else if (Date.now() - lastStableTime >= stableMs) { + // Count has been stable for stableMs + return { count: currentCount, json }; + } + + await page.waitForTimeout(200); + } + + // Timeout reached, return whatever we have + return { count: lastCount, json: lastJson! }; +} + async function resetAudiobookById(page: Page, bookId: string) { const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); expect(res.ok() || res.status() === 404).toBeTruthy(); @@ -96,264 +131,336 @@ async function resetAudiobookIfPresent(page: Page) { await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } -test.describe('Audiobook export', () => { - test.describe.configure({ mode: 'serial', timeout: 120_000 }); +test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => { + // Ensure TTS is mocked and app is ready + await setupTest(page); - test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => { - // Ensure TTS is mocked and app is ready - await setupTest(page); + // Upload and open the sample PDF in the viewer + await uploadAndDisplay(page, 'sample.pdf'); - // Upload and open the sample PDF in the viewer - await uploadAndDisplay(page, 'sample.pdf'); + // Capture the generated document/book id from the /pdf/[id] URL + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); - // Capture the generated document/book id from the /pdf/[id] URL - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); + // Open the audiobook export modal from the header button + await openExportModal(page); - // Open the audiobook export modal from the header button - await openExportModal(page); + // While there are no chapters yet, we can still switch the container format. + // Choose MP3 so we can validate MP3 duration end-to-end. + await setContainerFormatToMP3(page); - // While there are no chapters yet, we can still switch the container format. - // Choose MP3 so we can validate MP3 duration end-to-end. - await setContainerFormatToMP3(page); + // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page + await startGeneration(page); - // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page - await startGeneration(page); + // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) + await waitForChaptersHeading(page); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); - // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) - await waitForChaptersHeading(page); - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); + // Trigger full download from the FRONTEND button and capture via Playwright's download API. + // The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on + // the server-side detected format, so match more loosely on the accessible name. + const downloadedPath = await downloadFullAudiobook(page); - // Trigger full download from the FRONTEND button and capture via Playwright's download API. - // The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on - // the server-side detected format, so match more loosely on the accessible name. - const downloadedPath = await downloadFullAudiobook(page); + // Use ffprobe (same toolchain as the server) to validate the combined audio duration. + // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least + // two chapters we should be close to ~20 seconds of audio. + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // Duration must be within a reasonable window around 20 seconds to allow + // for encoding variations and container overhead. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); - // Use ffprobe (same toolchain as the server) to validate the combined audio duration. - // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least - // two chapters we should be close to ~20 seconds of audio. - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // Duration must be within a reasonable window around 20 seconds to allow - // for encoding variations and container overhead. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); + // Also check the chapter metadata API for consistency + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBeGreaterThanOrEqual(2); + for (const ch of json.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } - // Also check the chapter metadata API for consistency - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBeGreaterThanOrEqual(2); - for (const ch of json.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - await resetAudiobookIfPresent(page); - }); - - test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => { - await setupTest(page); - - // Upload and open the sample EPUB in the viewer - await uploadAndDisplay(page, 'sample.epub'); - - // URL should now be /epub/[id] - const bookId = await getBookIdFromUrl(page, 'epub'); - await resetAudiobookById(page, bookId); - - // Open the audiobook export modal from the header button - await openExportModal(page); - - // Set container format to MP3 - await setContainerFormatToMP3(page); - - // Start generation - await startGeneration(page); - - // Progress card should appear with a Cancel button while chapters are being generated - const cancelButton = page.getByRole('button', { name: 'Cancel' }); - await expect(cancelButton).toBeVisible({ timeout: 60_000 }); - - await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); - - // Wait until at least 3 chapters are listed in the UI; record the exact count at the - // moment we decide to cancel, and assert that no additional chapters are added afterward. - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 }); - const chapterCountBeforeCancel = await chapterActionsButtons.count(); - expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3); - - // Now cancel the in-flight generation - await cancelButton.click(); - - // After cancellation, the inline progress card's Cancel button should be gone - await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0); - - // After cancellation, determine the canonical chapter count from the backend and - // assert that the UI eventually reflects this count. Some in-flight chapters may - // complete right as we cancel, so we treat the backend state as source of truth. - const jsonAfterCancel = await expectChaptersBackendState(page, bookId); - expect(jsonAfterCancel.exists).toBe(true); - expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); - const chapterCountAfterCancel = jsonAfterCancel.chapters.length; - expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel); - - // Wait for the UI to reflect the final backend chapter count to avoid race - // conditions between the modal's soft refresh and our assertions. - await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 }); - - // The Full Download button should still be available for the partially generated audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - expect(durationSeconds).toBeGreaterThan(25); - expect(durationSeconds).toBeLessThan(300); - - // Backend should still reflect the same number of chapters as when we first - // observed the stabilized post-cancellation state, and should not contain - // additional "impartial" chapters produced after cancellation. - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(chapterCountAfterCancel); - - await resetAudiobookIfPresent(page); - }); - - test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - await openExportModal(page); - await setContainerFormatToMP3(page); - await startGeneration(page); - - await waitForChaptersHeading(page); - - // Wait for at least one chapter row to appear (one "Chapter actions" button) - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); - - // Download via frontend button - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. - expect(durationSeconds).toBeGreaterThan(9); - expect(durationSeconds).toBeLessThan(300); - - await resetAudiobookIfPresent(page); - }); - - test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - await openExportModal(page); - await setContainerFormatToMP3(page); - await startGeneration(page); - - await waitForChaptersHeading(page); - - // Wait for Reset button to become visible, indicating resumable/generated state - const resetButton = page.getByRole('button', { name: 'Reset' }); - await expect(resetButton).toBeVisible({ timeout: 120_000 }); - - await resetButton.click(); - - // Confirm in the Reset Audiobook dialog - await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 }); - const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); - await confirmReset.click(); - - // After reset, generation should be startable again - await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); - - // Backend should report no existing chapters for this bookId - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); - expect(res.ok()).toBeTruthy(); - const json = await res.json(); - expect(json.exists).toBe(false); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(0); - }); - - test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - // Extract bookId from /pdf/[id] URL (for backend verification later) - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - // Open Export Audiobook modal - await openExportModal(page); - - // Set container format to MP3 - await setContainerFormatToMP3(page); - - // Start generation - await startGeneration(page); - - // Wait for chapters to appear - await waitForChaptersHeading(page); - - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - // Ensure we have at least two chapters for this PDF - await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 }); - const chapterCountBefore = await chapterActionsButtons.count(); - expect(chapterCountBefore).toBeGreaterThanOrEqual(2); - - // Open the actions menu for the first chapter and trigger Regenerate - const firstChapterActions = chapterActionsButtons.first(); - await firstChapterActions.click(); - - // In the headlessui Menu, each option is a menuitem. Use that role instead of button. - const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i }); - await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 }); - await regenerateMenuItem.click(); - - // During regeneration, the row may show a "Regenerating" label; wait for any such - // indicator to disappear, signaling completion. - const regeneratingLabel = page.getByText(/Regenerating/); - await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 }); - - // After regeneration completes in the UI, verify backend chapter state is fully updated - // before triggering a full download to avoid races with ffmpeg concat on Alpine. - const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId); - expect(backendStateAfterRegenerate.exists).toBe(true); - expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true); - expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore); - for (const ch of backendStateAfterRegenerate.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - // Chapter count should remain exactly the same after regeneration (no duplicates) - await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); - - // Full Download should still work and produce a valid combined audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // With two mocked 10s chapters we expect roughly 20s; allow a small window. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); - - // Backend should still report the same number of chapters and valid durations - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(chapterCountBefore); - for (const ch of json.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - await resetAudiobookIfPresent(page); - }); + await resetAudiobookIfPresent(page); +}); + +test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }) => { + await setupTest(page); + + // Upload and open the sample EPUB in the viewer + await uploadAndDisplay(page, 'sample.epub'); + + // URL should now be /epub/[id] + const bookId = await getBookIdFromUrl(page, 'epub'); + await resetAudiobookById(page, bookId); + + // Open the audiobook export modal from the header button + await openExportModal(page); + + // Set container format to MP3 + await setContainerFormatToMP3(page); + + // Start generation + await startGeneration(page); + + // Progress card should appear with a Cancel button while chapters are being generated + const cancelButton = page.getByRole('button', { name: 'Cancel' }); + await expect(cancelButton).toBeVisible({ timeout: 60_000 }); + + await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); + + // Wait until at least 3 chapters are listed in the UI; record the exact count at the + // moment we decide to cancel, and assert that no additional chapters are added afterward. + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 }); + const chapterCountBeforeCancel = await chapterActionsButtons.count(); + expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3); + + // Now cancel the in-flight generation + await cancelButton.click(); + + // After cancellation, the inline progress card's Cancel button should be gone + await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0); + + // After cancellation, wait for the chapter count to stabilize. In-flight TTS + // requests may still complete after we click cancel, so we poll until the + // count stops changing for a brief period. + const { count: chapterCountAfterCancel, json: jsonAfterCancel } = await waitForStableChapterCount( + page, + bookId, + { stableMs: 2000, timeoutMs: 30000 } + ); + expect(jsonAfterCancel.exists).toBe(true); + expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); + expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel); + + // Wait for the UI to reflect the final backend chapter count to avoid race + // conditions between the modal's soft refresh and our assertions. + await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 }); + + // The Full Download button should still be available for the partially generated audiobook + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + expect(durationSeconds).toBeGreaterThan(25); + expect(durationSeconds).toBeLessThan(300); + + // Backend should still reflect the same number of chapters as when we first + // observed the stabilized post-cancellation state. + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(chapterCountAfterCancel); + + await resetAudiobookIfPresent(page); +}); + +test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + + // Wait for at least one chapter row to appear (one "Chapter actions" button) + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); + + // Download via frontend button + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. + expect(durationSeconds).toBeGreaterThan(9); + expect(durationSeconds).toBeLessThan(300); + + await resetAudiobookIfPresent(page); +}); + +test('resets all MP3 audiobook PDF pages', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + + // Wait for Reset button to become visible, indicating resumable/generated state + const resetButton = page.getByRole('button', { name: 'Reset' }); + await expect(resetButton).toBeVisible({ timeout: 120_000 }); + + await resetButton.click(); + + // Confirm in the Reset Audiobook dialog + await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 }); + const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); + await confirmReset.click(); + + // After reset, generation should be startable again + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); + + // Backend should report no existing chapters for this bookId + const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); + expect(res.ok()).toBeTruthy(); + const json = await res.json(); + expect(json.exists).toBe(false); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(0); +}); + +test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + // Extract bookId from /pdf/[id] URL (for backend verification later) + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + // Open Export Audiobook modal + await openExportModal(page); + + // Set container format to MP3 + await setContainerFormatToMP3(page); + + // Start generation + await startGeneration(page); + + // Wait for chapters to appear + await waitForChaptersHeading(page); + + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + // Ensure we have at least two chapters for this PDF + await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 }); + const chapterCountBefore = await chapterActionsButtons.count(); + expect(chapterCountBefore).toBeGreaterThanOrEqual(2); + + // Open the actions menu for the first chapter and trigger Regenerate + const firstChapterActions = chapterActionsButtons.first(); + await firstChapterActions.click(); + + // In the headlessui Menu, each option is a menuitem. Use that role instead of button. + const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i }); + await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 }); + await regenerateMenuItem.click(); + + // During regeneration, the row may show a "Regenerating" label; wait for any such + // indicator to disappear, signaling completion. + const regeneratingLabel = page.getByText(/Regenerating/); + await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 }); + + // After regeneration completes in the UI, verify backend chapter state is fully updated + // before triggering a full download to avoid races with ffmpeg concat on Alpine. + const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId); + expect(backendStateAfterRegenerate.exists).toBe(true); + expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true); + expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore); + for (const ch of backendStateAfterRegenerate.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + // Chapter count should remain exactly the same after regeneration (no duplicates) + await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); + + // Full Download should still work and produce a valid combined audiobook + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // With two mocked 10s chapters we expect roughly 20s; allow a small window. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + + // Backend should still report the same number of chapters and valid durations + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(chapterCountBefore); + for (const ch of json.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + await resetAudiobookIfPresent(page); +}); + +test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); + + // Delete the first chapter via the backend API so the audiobook has a missing index (0). + // This is more reliable than clicking through the chapter actions menu in headless runs. + const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`); + expect(deleteRes.ok()).toBeTruthy(); + + // Wait for backend to reflect only one remaining chapter (index 1). + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 30_000 }) + .toBe(1); + + const jsonAfterDelete = await expectChaptersBackendState(page, bookId); + expect(jsonAfterDelete.exists).toBe(true); + expect(Array.isArray(jsonAfterDelete.chapters)).toBe(true); + expect(jsonAfterDelete.chapters.length).toBe(1); + expect(jsonAfterDelete.chapters[0]?.index).toBe(1); + + // Close and reopen the modal to ensure "resume" loads the missing placeholder from the backend. + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toHaveCount(0); + await openExportModal(page); + + await waitForChaptersHeading(page); + await expect(page.getByText(/Missing •/)).toHaveCount(1, { timeout: 15_000 }); + await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 15_000 }); + + // Resume should regenerate the missing chapter and allow a full download to succeed. + await page.getByRole('button', { name: 'Resume' }).click(); + + // Wait for backend to have both chapters again. + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 120_000 }) + .toBe(2); + + // UI should also stop showing a missing placeholder after resume completes. + await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 }); + + const downloadedPath = await downloadFullAudiobook(page); + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + + const jsonAfterResume = await expectChaptersBackendState(page, bookId); + expect(jsonAfterResume.exists).toBe(true); + expect(Array.isArray(jsonAfterResume.chapters)).toBe(true); + expect(jsonAfterResume.chapters.length).toBe(2); + expect(jsonAfterResume.chapters.map((c: { index: number }) => c.index).sort()).toEqual([0, 1]); + for (const ch of jsonAfterResume.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + await resetAudiobookIfPresent(page); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 3121469..de3cfb3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -6,50 +6,6 @@ const DIR = './tests/files/'; const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3'); let ttsMockBuffer: Buffer | null = null; -type RateLimitStatusResponse = { - authEnabled: boolean; - userType?: 'anonymous' | 'authenticated' | 'unauthenticated'; -}; - -async function getRateLimitStatus(page: Page): Promise { - try { - const res = await page.request.get('/api/rate-limit/status'); - if (!res.ok()) return null; - return (await res.json()) as RateLimitStatusResponse; - } catch { - return null; - } -} - -async function ensureAnonymousSession(page: Page): Promise { - const initial = await getRateLimitStatus(page); - if (!initial) return; - if (!initial.authEnabled) return; - if (initial.userType && initial.userType !== 'unauthenticated') return; - - // Create a session cookie for this test context. - // This avoids races where the app makes authenticated API calls before AuthLoader finishes. - try { - await page.request.post('/api/auth/sign-in/anonymous', { data: {} }); - } catch { - // ignore - } - - // Wait until the server sees us as anonymous/authenticated (i.e. cookie persisted). - const deadline = Date.now() + 15_000; - // eslint-disable-next-line no-constant-condition - while (true) { - const next = await getRateLimitStatus(page); - if (next && (!next.authEnabled || (next.userType && next.userType !== 'unauthenticated'))) { - return; - } - if (Date.now() > deadline) { - throw new Error('Timed out waiting for anonymous auth session in tests'); - } - await page.waitForTimeout(200); - } -} - async function ensureTtsRouteMock(page: Page) { if (!ttsMockBuffer) { ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);