refactor(audiobooks): implement user-specific storage with composite primary keys
- Change audiobooks and audiobookChapters tables to use composite PK (id, userId) - Migrate audiobooks from flat storage to user-specific directories under audiobooks_users - Add support for claiming unclaimed audiobooks on account creation - Improve auth rate limiting with retry logic and DISABLE_AUTH_RATE_LIMIT for tests - Fix iOS/Safari audio playback with unlock mechanism and playback rate watchdog - Update API routes to handle user-scoped audiobook access with fallback to unclaimed - Transfer audiobooks when linking anonymous accounts to real accounts - Remove foreign key constraint from audiobookChapters to support composite PK - Add cascade delete to account and session foreign keys
This commit is contained in:
parent
30ce65e747
commit
c24710b2ca
25 changed files with 1369 additions and 1863 deletions
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<void>((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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string | null>(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
|
||||
|
|
|
|||
|
|
@ -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<string[]>([]);
|
||||
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<ReturnType<typeof setInterval> | 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<void>).then === 'function') {
|
||||
void (p as Promise<void>)
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useMemo } from 'react';
|
|||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { getAuthClient } from '@/lib/auth-client';
|
||||
|
||||
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['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();
|
||||
|
|
|
|||
|
|
@ -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<Response | null> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<typeof expectChaptersBackendState> extends Promise<infer T> ? T : never }> {
|
||||
const startTime = Date.now();
|
||||
let lastCount = -1;
|
||||
let lastStableTime = Date.now();
|
||||
let lastJson: Awaited<ReturnType<typeof expectChaptersBackendState>> | 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<RateLimitStatusResponse | null> {
|
||||
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<void> {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue