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
|
--> statement-breakpoint
|
||||||
CREATE TABLE "audiobook_chapters" (
|
CREATE TABLE "audiobook_chapters" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text NOT NULL,
|
||||||
"book_id" text NOT NULL,
|
"book_id" text NOT NULL,
|
||||||
"user_id" text,
|
"user_id" text NOT NULL,
|
||||||
"chapter_index" integer NOT NULL,
|
"chapter_index" integer NOT NULL,
|
||||||
"title" text NOT NULL,
|
"title" text NOT NULL,
|
||||||
"duration" real DEFAULT 0,
|
"duration" real DEFAULT 0,
|
||||||
"file_path" text NOT NULL,
|
"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
|
--> statement-breakpoint
|
||||||
CREATE TABLE "audiobooks" (
|
CREATE TABLE "audiobooks" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text NOT NULL,
|
||||||
"user_id" text,
|
"user_id" text NOT NULL,
|
||||||
"title" text NOT NULL,
|
"title" text NOT NULL,
|
||||||
"author" text,
|
"author" text,
|
||||||
"description" text,
|
"description" text,
|
||||||
"cover_path" text,
|
"cover_path" text,
|
||||||
"duration" real DEFAULT 0,
|
"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
|
--> statement-breakpoint
|
||||||
CREATE TABLE "documents" (
|
CREATE TABLE "documents" (
|
||||||
|
|
@ -88,7 +90,6 @@ CREATE TABLE "verification" (
|
||||||
"updated_at" timestamp
|
"updated_at" timestamp
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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 "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 "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 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");
|
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",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -116,7 +116,7 @@
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"book_id": {
|
"book_id": {
|
||||||
|
|
@ -129,7 +129,7 @@
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": true
|
||||||
},
|
},
|
||||||
"chapter_index": {
|
"chapter_index": {
|
||||||
"name": "chapter_index",
|
"name": "chapter_index",
|
||||||
|
|
@ -164,22 +164,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {},
|
||||||
"audiobook_chapters_book_id_audiobooks_id_fk": {
|
"compositePrimaryKeys": {
|
||||||
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
|
"audiobook_chapters_id_user_id_pk": {
|
||||||
"tableFrom": "audiobook_chapters",
|
"name": "audiobook_chapters_id_user_id_pk",
|
||||||
"tableTo": "audiobooks",
|
"columns": [
|
||||||
"columnsFrom": [
|
"id",
|
||||||
"book_id"
|
"user_id"
|
||||||
],
|
]
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
|
|
@ -192,14 +186,14 @@
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"user_id": {
|
"user_id": {
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": true
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"name": "title",
|
"name": "title",
|
||||||
|
|
@ -242,7 +236,15 @@
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"audiobooks_id_user_id_pk": {
|
||||||
|
"name": "audiobooks_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
|
|
@ -383,7 +385,7 @@
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,15 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1769393893675,
|
"when": 1769641576464,
|
||||||
"tag": "0000_military_nemesis",
|
"tag": "0000_lucky_zarek",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769404444945,
|
|
||||||
"tag": "0001_futuristic_harpoon",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,30 +12,31 @@ CREATE TABLE `account` (
|
||||||
`password` text,
|
`password` text,
|
||||||
`created_at` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updated_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
|
--> statement-breakpoint
|
||||||
CREATE TABLE `audiobook_chapters` (
|
CREATE TABLE `audiobook_chapters` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text NOT NULL,
|
||||||
`book_id` text NOT NULL,
|
`book_id` text NOT NULL,
|
||||||
`user_id` text,
|
`user_id` text NOT NULL,
|
||||||
`chapter_index` integer NOT NULL,
|
`chapter_index` integer NOT NULL,
|
||||||
`title` text NOT NULL,
|
`title` text NOT NULL,
|
||||||
`duration` real DEFAULT 0,
|
`duration` real DEFAULT 0,
|
||||||
`file_path` text NOT NULL,
|
`file_path` text NOT NULL,
|
||||||
`format` 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
|
--> statement-breakpoint
|
||||||
CREATE TABLE `audiobooks` (
|
CREATE TABLE `audiobooks` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text NOT NULL,
|
||||||
`user_id` text,
|
`user_id` text NOT NULL,
|
||||||
`title` text NOT NULL,
|
`title` text NOT NULL,
|
||||||
`author` text,
|
`author` text,
|
||||||
`description` text,
|
`description` text,
|
||||||
`cover_path` text,
|
`cover_path` text,
|
||||||
`duration` real DEFAULT 0,
|
`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
|
--> statement-breakpoint
|
||||||
CREATE TABLE `documents` (
|
CREATE TABLE `documents` (
|
||||||
|
|
@ -59,7 +60,7 @@ CREATE TABLE `session` (
|
||||||
`ip_address` text,
|
`ip_address` text,
|
||||||
`user_agent` text,
|
`user_agent` text,
|
||||||
`user_id` text NOT NULL,
|
`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
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> 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",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "f5c06478-a702-4809-ab9b-3a63f11b8b0c",
|
"id": "af57dff3-a6ec-418e-9fa1-8197d6839e48",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"chapter_index": {
|
"chapter_index": {
|
||||||
|
|
@ -181,22 +181,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {},
|
||||||
"audiobook_chapters_book_id_audiobooks_id_fk": {
|
"compositePrimaryKeys": {
|
||||||
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
|
"audiobook_chapters_id_user_id_pk": {
|
||||||
"tableFrom": "audiobook_chapters",
|
"columns": [
|
||||||
"tableTo": "audiobooks",
|
"id",
|
||||||
"columnsFrom": [
|
"user_id"
|
||||||
"book_id"
|
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"name": "audiobook_chapters_id_user_id_pk"
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
|
@ -206,7 +200,7 @@
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
|
@ -214,7 +208,7 @@
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
|
|
@ -264,7 +258,15 @@
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"audiobooks_id_user_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"name": "audiobooks_id_user_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
|
@ -423,7 +425,7 @@
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,15 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1769395269830,
|
"when": 1769641566451,
|
||||||
"tag": "0000_hard_mandarin",
|
"tag": "0000_steep_shaman",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1769535439728,
|
|
||||||
"tag": "0001_violet_mantis",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ export default defineConfig({
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
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',
|
url: 'http://localhost:3003',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,43 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import { readdir, unlink } from 'fs/promises';
|
import { readdir, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
|
import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, UNCLAIMED_USER_ID, isAudiobooksV1Ready } from '@/lib/server/docstore';
|
||||||
import { findStoredChapterByIndex } from '@/lib/server/audiobook';
|
import { findStoredChapterByIndex } from '@/lib/server/audiobook';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { audiobookChapters } from '@/db/schema';
|
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq, or } from 'drizzle-orm';
|
||||||
import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth';
|
import { requireAuthContext } from '@/lib/server/auth';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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();
|
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, '');
|
const getTestNamespacePath = (baseDir: string): string => {
|
||||||
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
@ -47,12 +69,31 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
if (ctxOrRes.authEnabled) {
|
const { userId, authEnabled } = ctxOrRes;
|
||||||
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
|
|
||||||
if (denied) return denied;
|
// 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);
|
const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal);
|
||||||
if (!chapter || !existsSync(chapter.filePath)) {
|
if (!chapter || !existsSync(chapter.filePath)) {
|
||||||
|
|
@ -128,20 +169,28 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
const { userId, authEnabled } = ctxOrRes;
|
||||||
|
|
||||||
if (ctxOrRes.authEnabled) {
|
// Verify ownership and delete from DB with composite PK
|
||||||
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'forbidden' });
|
if (authEnabled && db && userId) {
|
||||||
if (denied) return denied;
|
const [existingBook] = await db.select().from(audiobooks).where(
|
||||||
|
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
|
||||||
// Delete from DB first
|
);
|
||||||
if (db) {
|
if (!existingBook) {
|
||||||
await db.delete(audiobookChapters).where(
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.chapterIndex, chapterIndex)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
|
||||||
const files = await readdir(intermediateDir).catch(() => []);
|
const files = await readdir(intermediateDir).catch(() => []);
|
||||||
for (const file of files) {
|
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 { existsSync, createReadStream } from 'fs';
|
||||||
import { basename, join, resolve } from 'path';
|
import { basename, join, resolve } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
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 { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook';
|
||||||
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
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 { isAuthEnabled } from '@/lib/server/auth-config';
|
||||||
import { getAuthContext, requireAuthContext } from '@/lib/server/auth';
|
import { requireAuthContext } from '@/lib/server/auth';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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();
|
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, '');
|
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
|
||||||
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
|
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
|
||||||
return AUDIOBOOKS_V1_DIR;
|
return baseDir;
|
||||||
}
|
}
|
||||||
const resolved = resolve(AUDIOBOOKS_V1_DIR, safe);
|
const resolved = resolve(baseDir, safe);
|
||||||
if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) {
|
if (!resolved.startsWith(resolve(baseDir) + '/')) {
|
||||||
return AUDIOBOOKS_V1_DIR;
|
return baseDir;
|
||||||
}
|
}
|
||||||
return resolved;
|
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 {
|
interface ConversionRequest {
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
buffer: TTSAudioBytes;
|
buffer: TTSAudioBytes;
|
||||||
|
|
@ -183,8 +202,10 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB Check / Insert Audiobook
|
// DB Check / Insert Audiobook
|
||||||
if (isAuthEnabled() && db) {
|
if (isAuthEnabled() && db && userId) {
|
||||||
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
|
const [existingBook] = await db.select().from(audiobooks).where(
|
||||||
|
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingBook) {
|
if (!existingBook) {
|
||||||
await db.insert(audiobooks).values({
|
await db.insert(audiobooks).values({
|
||||||
|
|
@ -192,14 +213,10 @@ export async function POST(request: NextRequest) {
|
||||||
userId,
|
userId,
|
||||||
title: data.chapterTitle || 'Untitled Audiobook',
|
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
|
// Create intermediate directory
|
||||||
await mkdir(intermediateDir, { recursive: true });
|
await mkdir(intermediateDir, { recursive: true });
|
||||||
|
|
@ -346,7 +363,7 @@ export async function POST(request: NextRequest) {
|
||||||
await unlink(inputPath).catch(console.error);
|
await unlink(inputPath).catch(console.error);
|
||||||
|
|
||||||
// Insert Chapter Record (Denormalized)
|
// Insert Chapter Record (Denormalized)
|
||||||
if (isAuthEnabled() && db) {
|
if (isAuthEnabled() && db && userId) {
|
||||||
await db.insert(audiobookChapters).values({
|
await db.insert(audiobookChapters).values({
|
||||||
id: `${bookId}-${chapterIndex}`,
|
id: `${bookId}-${chapterIndex}`,
|
||||||
bookId,
|
bookId,
|
||||||
|
|
@ -357,7 +374,7 @@ export async function POST(request: NextRequest) {
|
||||||
format,
|
format,
|
||||||
filePath: finalChapterName
|
filePath: finalChapterName
|
||||||
}).onConflictDoUpdate({
|
}).onConflictDoUpdate({
|
||||||
target: [audiobookChapters.id],
|
target: [audiobookChapters.id, audiobookChapters.userId],
|
||||||
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName }
|
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
|
// Check if audiobook exists for user OR is unclaimed (similar to documents)
|
||||||
if (isAuthEnabled() && db) {
|
if (authEnabled && db && userId) {
|
||||||
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
|
const [existingBook] = await db.select().from(audiobooks).where(
|
||||||
if (existingBook && existingBook.userId && existingBook.userId !== userId) {
|
and(
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 }); // Hide existence
|
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)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||||
|
|
@ -610,24 +643,29 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const ctxOrRes = await requireAuthContext(request);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
const userId = ctxOrRes.userId;
|
const { userId, authEnabled } = ctxOrRes;
|
||||||
|
|
||||||
// Verify ownership
|
// Delete from DB - with composite PK, we delete by both id and userId
|
||||||
if (isAuthEnabled() && db) {
|
if (authEnabled && db && userId) {
|
||||||
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
|
const [existingBook] = await db.select().from(audiobooks).where(
|
||||||
|
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
|
||||||
|
);
|
||||||
|
|
||||||
if (existingBook) {
|
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 {
|
|
||||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
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 directory doesn't exist, consider it already reset
|
||||||
if (!existsSync(intermediateDir)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,45 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
|
import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUserAudiobookDir, isAudiobooksV1Ready } from '@/lib/server/docstore';
|
||||||
import { listStoredChapters } from '@/lib/server/audiobook';
|
import { listStoredChapters } from '@/lib/server/audiobook';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
|
||||||
import { readFile } from 'fs/promises';
|
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';
|
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();
|
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, '');
|
const applyTestNamespace = (baseDir: string): string => {
|
||||||
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
|
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}$/;
|
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);
|
const ctxOrRes = await requireAuthContext(request);
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
|
||||||
if (ctxOrRes.authEnabled) {
|
const { userId, authEnabled } = ctxOrRes;
|
||||||
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
|
|
||||||
if (denied) {
|
// 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({
|
return NextResponse.json({
|
||||||
chapters: [],
|
chapters: [],
|
||||||
exists: false,
|
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)) {
|
if (!existsSync(intermediateDir)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,97 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState, ReactNode } from 'react';
|
import { useEffect, useRef, useState, ReactNode } from 'react';
|
||||||
|
import type { BetterFetchError } from 'better-auth/react';
|
||||||
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { getAuthClient } from '@/lib/auth-client';
|
import { getAuthClient } from '@/lib/auth-client';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
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 }) {
|
export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
const { authEnabled, baseUrl } = useAuthConfig();
|
const { authEnabled, baseUrl } = useAuthConfig();
|
||||||
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
const { refresh: refreshRateLimit } = useAuthRateLimit();
|
||||||
const { data: session, isPending } = useAuthSession();
|
const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession();
|
||||||
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
|
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
|
||||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
const [retryNonce, setRetryNonce] = useState(0);
|
||||||
|
|
@ -43,18 +125,78 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = getAuthClient(baseUrl);
|
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();
|
await refreshRateLimit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Auto-login failed', err);
|
console.error('[AuthLoader] auto-login failed', err);
|
||||||
setBootstrapError('Unable to start an anonymous session.');
|
setBootstrapError('Unable to start an anonymous session (rate limited or network error).');
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoLoggingIn(false);
|
setIsAutoLoggingIn(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkStatus();
|
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:
|
// Show loader if:
|
||||||
// 1. Auth client is initializing (isPending) AND auth is enabled
|
// 1. Auth client is initializing (isPending) AND auth is enabled
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ interface SetTextOptions {
|
||||||
const CONTINUATION_LOOKAHEAD = 600;
|
const CONTINUATION_LOOKAHEAD = 600;
|
||||||
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
|
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) =>
|
const normalizeLocationKey = (location: TTSLocation) =>
|
||||||
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
typeof location === 'number' ? `num:${location}` : `str:${location}`;
|
||||||
|
|
||||||
|
|
@ -367,6 +371,109 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const sentencesRef = useRef<string[]>([]);
|
const sentencesRef = useRef<string[]>([]);
|
||||||
const currentIndexRef = useRef(0);
|
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(() => {
|
useEffect(() => {
|
||||||
sentencesRef.current = sentences;
|
sentencesRef.current = sentences;
|
||||||
}, [sentences]);
|
}, [sentences]);
|
||||||
|
|
@ -395,6 +502,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* @param {boolean} [clearPending=false] - Whether to clear pending requests
|
* @param {boolean} [clearPending=false] - Whether to clear pending requests
|
||||||
*/
|
*/
|
||||||
const abortAudio = useCallback((clearPending = false) => {
|
const abortAudio = useCallback((clearPending = false) => {
|
||||||
|
clearRateWatchdog();
|
||||||
if (activeHowl) {
|
if (activeHowl) {
|
||||||
activeHowl.stop();
|
activeHowl.stop();
|
||||||
activeHowl.unload();
|
activeHowl.unload();
|
||||||
|
|
@ -414,7 +522,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
pageTurnTimeoutRef.current = null;
|
pageTurnTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
setCurrentWordIndex(null);
|
setCurrentWordIndex(null);
|
||||||
}, [activeHowl]);
|
}, [activeHowl, clearRateWatchdog]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pauses the current audio playback
|
* Pauses the current audio playback
|
||||||
|
|
@ -666,15 +774,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* Toggles the playback state between playing and paused
|
* Toggles the playback state between playing and paused
|
||||||
*/
|
*/
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
setIsPlaying((prev) => {
|
if (isPlaying) {
|
||||||
if (!prev) {
|
abortAudio();
|
||||||
return true;
|
setIsPlaying(false);
|
||||||
} else {
|
return;
|
||||||
abortAudio();
|
}
|
||||||
return false;
|
|
||||||
}
|
// Ensure audio is unlocked while we're still in the click/tap handler.
|
||||||
});
|
unlockPlaybackOnUserGesture();
|
||||||
}, [abortAudio]);
|
setIsPlaying(true);
|
||||||
|
}, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1068,6 +1177,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
pool: 1,
|
pool: 1,
|
||||||
rate: audioSpeed,
|
rate: audioSpeed,
|
||||||
onload: function (this: Howl) {
|
onload: function (this: Howl) {
|
||||||
|
applyPlaybackRateToHowl(this);
|
||||||
const estimate = pageTurnEstimateRef.current;
|
const estimate = pageTurnEstimateRef.current;
|
||||||
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
|
||||||
if (!visualPageChangeHandlerRef.current) return;
|
if (!visualPageChangeHandlerRef.current) return;
|
||||||
|
|
@ -1089,14 +1199,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
visualPageChangeHandlerRef.current?.(currentEstimate.location);
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
},
|
},
|
||||||
onplay: () => {
|
onplay: function (this: Howl) {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
startRateWatchdog(this);
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.playbackState = 'playing';
|
navigator.mediaSession.playbackState = 'playing';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onplayerror: function (this: Howl, error) {
|
onplayerror: function (this: Howl, soundId, error) {
|
||||||
console.warn('Howl playback error:', 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;
|
playErrorAttempts += 1;
|
||||||
|
|
||||||
// Avoid looping for many seconds on Safari: if playback still fails after a single
|
// 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);
|
setActiveHowl(null);
|
||||||
this.unload();
|
this.unload();
|
||||||
setIsPlaying(false);
|
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();
|
advance();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1117,8 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onloaderror: async function (this: Howl, error) {
|
onloaderror: async function (this: Howl, soundId, error) {
|
||||||
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
|
const actualError = error ?? soundId;
|
||||||
|
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
// Calculate exponential backoff delay
|
// Calculate exponential backoff delay
|
||||||
|
|
@ -1184,6 +1331,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onend: function (this: Howl) {
|
onend: function (this: Howl) {
|
||||||
|
clearRateWatchdog();
|
||||||
this.unload();
|
this.unload();
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
if (pageTurnTimeoutRef.current) {
|
if (pageTurnTimeoutRef.current) {
|
||||||
|
|
@ -1195,6 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onstop: function (this: Howl) {
|
onstop: function (this: Howl) {
|
||||||
|
clearRateWatchdog();
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
this.unload();
|
this.unload();
|
||||||
}
|
}
|
||||||
|
|
@ -1225,7 +1374,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
advance();
|
advance();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
}, [
|
||||||
|
abortAudio,
|
||||||
|
isPlaying,
|
||||||
|
advance,
|
||||||
|
activeHowl,
|
||||||
|
processSentence,
|
||||||
|
audioSpeed,
|
||||||
|
isAutoplayBlockedError,
|
||||||
|
applyPlaybackRateToHowl,
|
||||||
|
startRateWatchdog,
|
||||||
|
clearRateWatchdog,
|
||||||
|
]);
|
||||||
|
|
||||||
const playAudio = useCallback(async () => {
|
const playAudio = useCallback(async () => {
|
||||||
const sentence = sentences[currentIndex];
|
const sentence = sentences[currentIndex];
|
||||||
|
|
@ -1251,6 +1411,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}
|
}
|
||||||
}, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]);
|
}, [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
|
// Place useBackgroundState after playAudio is defined
|
||||||
const isBackgrounded = useBackgroundState({
|
const isBackgrounded = useBackgroundState({
|
||||||
activeHowl,
|
activeHowl,
|
||||||
|
|
@ -1408,9 +1578,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const stopAndPlayFromIndex = useCallback((index: number) => {
|
const stopAndPlayFromIndex = useCallback((index: number) => {
|
||||||
abortAudio();
|
abortAudio();
|
||||||
|
|
||||||
|
// Same autoplay-unlock issue as togglePlay when starting from a fresh load.
|
||||||
|
unlockPlaybackOnUserGesture();
|
||||||
|
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}, [abortAudio]);
|
}, [abortAudio, unlockPlaybackOnUserGesture]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the speed and restarts the playback
|
* Sets the speed and restarts the playback
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,30 @@ export const documents = pgTable('documents', {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const audiobooks = pgTable('audiobooks', {
|
export const audiobooks = pgTable('audiobooks', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id'),
|
userId: text('user_id').notNull(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
author: text('author'),
|
author: text('author'),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
coverPath: text('cover_path'),
|
coverPath: text('cover_path'),
|
||||||
duration: real('duration').default(0),
|
duration: real('duration').default(0),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
});
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const audiobookChapters = pgTable('audiobook_chapters', {
|
export const audiobookChapters = pgTable('audiobook_chapters', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').notNull(),
|
||||||
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
|
bookId: text('book_id').notNull(),
|
||||||
userId: text('user_id'),
|
userId: text('user_id').notNull(),
|
||||||
chapterIndex: integer('chapter_index').notNull(),
|
chapterIndex: integer('chapter_index').notNull(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
duration: real('duration').default(0),
|
duration: real('duration').default(0),
|
||||||
filePath: text('file_path').notNull(),
|
filePath: text('file_path').notNull(),
|
||||||
format: text('format').notNull(), // mp3, m4b
|
format: text('format').notNull(), // mp3, m4b
|
||||||
});
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
|
|
@ -15,26 +15,30 @@ export const documents = sqliteTable('documents', {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const audiobooks = sqliteTable('audiobooks', {
|
export const audiobooks = sqliteTable('audiobooks', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id'),
|
userId: text('user_id').notNull(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
author: text('author'),
|
author: text('author'),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
coverPath: text('cover_path'),
|
coverPath: text('cover_path'),
|
||||||
duration: real('duration').default(0),
|
duration: real('duration').default(0),
|
||||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
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', {
|
export const audiobookChapters = sqliteTable('audiobook_chapters', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').notNull(),
|
||||||
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
|
bookId: text('book_id').notNull(),
|
||||||
userId: text('user_id'),
|
userId: text('user_id').notNull(),
|
||||||
chapterIndex: integer('chapter_index').notNull(),
|
chapterIndex: integer('chapter_index').notNull(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
duration: real('duration').default(0),
|
duration: real('duration').default(0),
|
||||||
filePath: text('file_path').notNull(),
|
filePath: text('file_path').notNull(),
|
||||||
format: text('format').notNull(), // mp3, m4b
|
format: text('format').notNull(), // mp3, m4b
|
||||||
});
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.id, table.userId] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const user = sqliteTable("user", {
|
export const user = sqliteTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useMemo } from 'react';
|
||||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||||
import { getAuthClient } from '@/lib/auth-client';
|
import { getAuthClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['useSession']>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for session that uses the correct baseUrl from context
|
* Hook for session that uses the correct baseUrl from context
|
||||||
*/
|
*/
|
||||||
|
|
@ -16,7 +18,18 @@ export function useAuthSession() {
|
||||||
}, [baseUrl, authEnabled]);
|
}, [baseUrl, authEnabled]);
|
||||||
|
|
||||||
if (!client) {
|
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();
|
return client.useSession();
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@ import type { NextRequest } from 'next/server';
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { rateLimiter } from "@/lib/server/rate-limiter";
|
import { rateLimiter } from "@/lib/server/rate-limiter";
|
||||||
import { isAuthEnabled } from "@/lib/server/auth-config";
|
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
|
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);
|
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: {
|
socialProviders: {
|
||||||
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
|
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
|
||||||
github: {
|
github: {
|
||||||
|
|
@ -72,6 +75,17 @@ const createAuth = () => betterAuth({
|
||||||
console.error("Error transferring rate limit data during account linking:", error);
|
console.error("Error transferring rate limit data during account linking:", error);
|
||||||
// Don't throw here to prevent blocking the account linking process
|
// 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) {
|
} catch (error) {
|
||||||
console.error("Error in onLinkAccount callback:", error);
|
console.error("Error in onLinkAccount callback:", error);
|
||||||
// Don't throw here to prevent blocking the account linking process
|
// Don't throw here to prevent blocking the account linking process
|
||||||
|
|
@ -129,31 +143,3 @@ export async function requireAuthContext(
|
||||||
|
|
||||||
return ctx;
|
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 { db } from '@/db';
|
||||||
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
|
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 fs from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
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 { listStoredChapters } from './audiobook';
|
||||||
|
|
||||||
import { isAuthEnabled } from '@/lib/server/auth-config';
|
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) {
|
async function isDocumentIndexed(id: string) {
|
||||||
if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently?
|
if (!isAuthEnabled() || !db) return false;
|
||||||
// Actually if no DB, we don't index into DB. So returning false is fine,
|
|
||||||
// but scanAndPopulateDB should probably return early if no DB.
|
|
||||||
|
|
||||||
const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id));
|
const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id));
|
||||||
return result.length > 0;
|
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;
|
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;
|
return result.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNCLAIMED_ID = 'unclaimed';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed')
|
* Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed')
|
||||||
*/
|
*/
|
||||||
export async function getUnclaimedCounts() {
|
export async function getUnclaimedCounts() {
|
||||||
if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 };
|
if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 };
|
||||||
const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.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_ID));
|
const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents: docCount?.count ?? 0,
|
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() {
|
export async function scanAndPopulateDB() {
|
||||||
if (!isAuthEnabled() || !db) {
|
if (!isAuthEnabled() || !db) {
|
||||||
console.log('Skipping DB population (Auth/DB disabled)');
|
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...');
|
console.log('Scanning file system for un-indexed content...');
|
||||||
|
|
||||||
// 0. Fix legacy NULL userIds
|
// 0. Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to unclaimed folder
|
||||||
try {
|
await migrateLegacyAudiobooksToUnclaimed();
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Scan Documents
|
// 1. Scan Documents (unchanged - documents use shared storage)
|
||||||
if (existsSync(DOCUMENTS_V1_DIR)) {
|
if (existsSync(DOCUMENTS_V1_DIR)) {
|
||||||
const files = await fs.readdir(DOCUMENTS_V1_DIR);
|
const files = await fs.readdir(DOCUMENTS_V1_DIR);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|
@ -86,7 +118,7 @@ export async function scanAndPopulateDB() {
|
||||||
|
|
||||||
await db.insert(documents).values({
|
await db.insert(documents).values({
|
||||||
id,
|
id,
|
||||||
userId: UNCLAIMED_ID,
|
userId: UNCLAIMED_USER_ID,
|
||||||
name,
|
name,
|
||||||
type: ext,
|
type: ext,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
|
|
@ -97,17 +129,18 @@ export async function scanAndPopulateDB() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Scan Audiobooks
|
// 2. Scan Audiobooks from unclaimed folder
|
||||||
if (existsSync(AUDIOBOOKS_V1_DIR)) {
|
const unclaimedDir = getUnclaimedAudiobookDir();
|
||||||
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
|
if (existsSync(unclaimedDir)) {
|
||||||
|
const entries = await fs.readdir(unclaimedDir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
if (!entry.name.endsWith('-audiobook')) continue;
|
if (!entry.name.endsWith('-audiobook')) continue;
|
||||||
|
|
||||||
const bookId = entry.name.replace('-audiobook', '');
|
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`;
|
let title = `Unknown Title`;
|
||||||
|
|
||||||
|
|
@ -126,7 +159,7 @@ export async function scanAndPopulateDB() {
|
||||||
|
|
||||||
await db.insert(audiobooks).values({
|
await db.insert(audiobooks).values({
|
||||||
id: bookId,
|
id: bookId,
|
||||||
userId: UNCLAIMED_ID,
|
userId: UNCLAIMED_USER_ID,
|
||||||
title: title,
|
title: title,
|
||||||
duration: totalDuration,
|
duration: totalDuration,
|
||||||
});
|
});
|
||||||
|
|
@ -136,13 +169,13 @@ export async function scanAndPopulateDB() {
|
||||||
await db.insert(audiobookChapters).values({
|
await db.insert(audiobookChapters).values({
|
||||||
id: `${bookId}-${chapter.index}`,
|
id: `${bookId}-${chapter.index}`,
|
||||||
bookId: bookId,
|
bookId: bookId,
|
||||||
userId: UNCLAIMED_ID,
|
userId: UNCLAIMED_USER_ID,
|
||||||
chapterIndex: chapter.index,
|
chapterIndex: chapter.index,
|
||||||
title: chapter.title,
|
title: chapter.title,
|
||||||
duration: chapter.durationSec || 0,
|
duration: chapter.durationSec || 0,
|
||||||
filePath: chapter.filePath,
|
filePath: chapter.filePath,
|
||||||
format: chapter.format
|
format: chapter.format
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,25 +187,134 @@ export async function scanAndPopulateDB() {
|
||||||
export async function claimAnonymousData(userId: string) {
|
export async function claimAnonymousData(userId: string) {
|
||||||
if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 };
|
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)
|
const docResult = await db.update(documents)
|
||||||
.set({ userId })
|
.set({ userId })
|
||||||
.where(eq(documents.userId, UNCLAIMED_ID))
|
.where(eq(documents.userId, UNCLAIMED_USER_ID))
|
||||||
.returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes
|
.returning({ id: documents.id });
|
||||||
|
|
||||||
// Update Audiobooks
|
// For audiobooks, we need to:
|
||||||
const bookResult = await db.update(audiobooks)
|
// 1. Move the physical folders from unclaimed to user's folder
|
||||||
.set({ userId })
|
// 2. Update the DB records
|
||||||
.where(eq(audiobooks.userId, UNCLAIMED_ID))
|
|
||||||
.returning({ id: audiobooks.id });
|
|
||||||
|
|
||||||
// Update Chapters (denormalized userId)
|
let audiobooksClaimedCount = 0;
|
||||||
await db.update(audiobookChapters)
|
const userDir = getUserAudiobookDir(userId);
|
||||||
.set({ userId })
|
await fs.mkdir(userDir, { recursive: true });
|
||||||
.where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join
|
|
||||||
|
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 {
|
return {
|
||||||
documents: docResult.length,
|
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 DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
|
||||||
export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
|
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_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_DIR = path.join(DOCSTORE_DIR, '.migrations');
|
||||||
const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json');
|
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> {
|
export async function ensureAudiobooksV1Ready(): Promise<boolean> {
|
||||||
await mkdir(DOCSTORE_DIR, { recursive: true });
|
await mkdir(DOCSTORE_DIR, { recursive: true });
|
||||||
await mkdir(AUDIOBOOKS_V1_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 state = await loadMigrationState();
|
||||||
const legacyDirsPresent = await hasLegacyAudiobookDirs();
|
const legacyDirsPresent = await hasLegacyAudiobookDirs();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export const RATE_LIMITS = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const safeDb = () => db as any;
|
const safeDb = () => db as any;
|
||||||
|
|
||||||
|
type UserTtsCharsInsert = typeof userTtsChars.$inferInsert;
|
||||||
|
type UserTtsCharsDateValue = UserTtsCharsInsert['date'];
|
||||||
|
type UserTtsCharsUpdatedAtValue = UserTtsCharsInsert['updatedAt'];
|
||||||
|
|
||||||
|
|
||||||
export interface RateLimitResult {
|
export interface RateLimitResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
|
|
@ -99,6 +103,14 @@ function getRowsAffected(result: unknown): number {
|
||||||
export class RateLimiter {
|
export class RateLimiter {
|
||||||
constructor() { }
|
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
|
* 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 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 userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
||||||
|
|
||||||
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
||||||
|
|
@ -134,6 +147,13 @@ export class RateLimiter {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!db) throw new Error("DB not initialized");
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return await safeDb().transaction(async (tx: any) => {
|
return await safeDb().transaction(async (tx: any) => {
|
||||||
// Ensure records exist for each bucket
|
// Ensure records exist for each bucket
|
||||||
|
|
@ -141,31 +161,30 @@ export class RateLimiter {
|
||||||
await tx.insert(userTtsChars)
|
await tx.insert(userTtsChars)
|
||||||
.values({
|
.values({
|
||||||
userId: bucket.key,
|
userId: bucket.key,
|
||||||
date: today as string,
|
date: dateValue,
|
||||||
charCount: 0,
|
charCount: 0,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [userTtsChars.userId, userTtsChars.date],
|
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) {
|
for (const bucket of buckets) {
|
||||||
const updateResult = await tx.update(userTtsChars)
|
const updateResult = await tx.update(userTtsChars)
|
||||||
.set({
|
.set({
|
||||||
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
|
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
|
||||||
updatedAt: new Date()
|
updatedAt,
|
||||||
})
|
})
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(userTtsChars.userId, bucket.key),
|
eq(userTtsChars.userId, bucket.key),
|
||||||
eq(userTtsChars.date, today as string),
|
eq(userTtsChars.date, dateValue),
|
||||||
lt(userTtsChars.charCount, bucket.limit)
|
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) {
|
if (getRowsAffected(updateResult) <= 0) {
|
||||||
throw new RateLimitExceeded();
|
throw new RateLimitExceeded();
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +195,7 @@ export class RateLimiter {
|
||||||
for (const bucket of buckets) {
|
for (const bucket of buckets) {
|
||||||
const result = await tx.select({ currentCount: userTtsChars.charCount })
|
const result = await tx.select({ currentCount: userTtsChars.charCount })
|
||||||
.from(userTtsChars)
|
.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;
|
const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0;
|
||||||
bucketResults.push({ currentCount, limit: bucket.limit });
|
bucketResults.push({ currentCount, limit: bucket.limit });
|
||||||
|
|
@ -272,38 +291,36 @@ export class RateLimiter {
|
||||||
|
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const today = new Date().toISOString().split('T')[0];
|
||||||
await safeDb().transaction(async (tx: any) => {
|
const dateValue = today as unknown as UserTtsCharsDateValue;
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
|
||||||
|
|
||||||
// Get anonymous user's current count
|
const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount })
|
||||||
const anonymousResult = await tx.select({ charCount: userTtsChars.charCount })
|
.from(userTtsChars)
|
||||||
.from(userTtsChars)
|
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue)));
|
||||||
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
|
|
||||||
|
|
||||||
if (anonymousResult.length > 0) {
|
if (anonymousResult.length === 0) return;
|
||||||
const anonymousCount = Number(anonymousResult[0].charCount);
|
|
||||||
|
|
||||||
const existingAuth = await tx.select({ charCount: userTtsChars.charCount })
|
const anonymousCount = Number(anonymousResult[0].charCount);
|
||||||
.from(userTtsChars)
|
|
||||||
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today)));
|
|
||||||
|
|
||||||
if (existingAuth.length === 0) {
|
const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount })
|
||||||
await tx.insert(userTtsChars).values({ userId: authenticatedUserId, date: today, charCount: anonymousCount });
|
.from(userTtsChars)
|
||||||
} else {
|
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove anonymous user's record
|
if (existingAuth.length === 0) {
|
||||||
await tx.delete(userTtsChars)
|
await safeDb().insert(userTtsChars)
|
||||||
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
|
.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();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||||
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
|
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
|
||||||
|
const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue;
|
||||||
|
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
// Assuming string comparison works for YYYY-MM-DD
|
// 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, cutoffDateValue));
|
||||||
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateStr as any));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getResetTime(): Date {
|
private getResetTime(): Date {
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,12 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) {
|
||||||
page.waitForEvent('download', { timeout: timeoutMs }),
|
page.waitForEvent('download', { timeout: timeoutMs }),
|
||||||
fullDownloadButton.click(),
|
fullDownloadButton.click(),
|
||||||
]);
|
]);
|
||||||
const downloadedPath = await download.path();
|
const tempFilePath = `./tmp_download_${Date.now()}.mp3`;
|
||||||
expect(downloadedPath).toBeTruthy();
|
await download.saveAs(tempFilePath);
|
||||||
const stats = fs.statSync(downloadedPath!);
|
expect(fs.existsSync(tempFilePath)).toBeTruthy();
|
||||||
|
const stats = fs.statSync(tempFilePath);
|
||||||
expect(stats.size).toBeGreaterThan(0);
|
expect(stats.size).toBeGreaterThan(0);
|
||||||
return downloadedPath!;
|
return tempFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudioDurationSeconds(filePath: string) {
|
async function getAudioDurationSeconds(filePath: string) {
|
||||||
|
|
@ -73,6 +74,40 @@ async function expectChaptersBackendState(page: Page, bookId: string) {
|
||||||
return json;
|
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) {
|
async function resetAudiobookById(page: Page, bookId: string) {
|
||||||
const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`);
|
const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`);
|
||||||
expect(res.ok() || res.status() === 404).toBeTruthy();
|
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 });
|
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Audiobook export', () => {
|
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
|
||||||
test.describe.configure({ mode: 'serial', timeout: 120_000 });
|
// 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 }) => {
|
// Upload and open the sample PDF in the viewer
|
||||||
// Ensure TTS is mocked and app is ready
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
await setupTest(page);
|
|
||||||
|
|
||||||
// Upload and open the sample PDF in the viewer
|
// Capture the generated document/book id from the /pdf/[id] URL
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
await resetAudiobookById(page, bookId);
|
||||||
|
|
||||||
// Capture the generated document/book id from the /pdf/[id] URL
|
// Open the audiobook export modal from the header button
|
||||||
const bookId = await getBookIdFromUrl(page, 'pdf');
|
await openExportModal(page);
|
||||||
await resetAudiobookById(page, bookId);
|
|
||||||
|
|
||||||
// Open the audiobook export modal from the header button
|
// While there are no chapters yet, we can still switch the container format.
|
||||||
await openExportModal(page);
|
// 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.
|
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
|
||||||
// Choose MP3 so we can validate MP3 duration end-to-end.
|
await startGeneration(page);
|
||||||
await setContainerFormatToMP3(page);
|
|
||||||
|
|
||||||
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
|
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
|
||||||
await startGeneration(page);
|
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)
|
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
|
||||||
await waitForChaptersHeading(page);
|
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
|
||||||
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
// the server-side detected format, so match more loosely on the accessible name.
|
||||||
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
|
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
|
||||||
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
|
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
|
||||||
// the server-side detected format, so match more loosely on the accessible name.
|
// two chapters we should be close to ~20 seconds of audio.
|
||||||
const downloadedPath = await downloadFullAudiobook(page);
|
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.
|
// Also check the chapter metadata API for consistency
|
||||||
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
// two chapters we should be close to ~20 seconds of audio.
|
expect(json.exists).toBe(true);
|
||||||
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
// Duration must be within a reasonable window around 20 seconds to allow
|
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
|
||||||
// for encoding variations and container overhead.
|
for (const ch of json.chapters) {
|
||||||
expect(durationSeconds).toBeGreaterThan(18);
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
expect(durationSeconds).toBeLessThan(22);
|
}
|
||||||
|
|
||||||
// Also check the chapter metadata API for consistency
|
await resetAudiobookIfPresent(page);
|
||||||
const json = await expectChaptersBackendState(page, bookId);
|
});
|
||||||
expect(json.exists).toBe(true);
|
|
||||||
expect(Array.isArray(json.chapters)).toBe(true);
|
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }) => {
|
||||||
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
|
await setupTest(page);
|
||||||
for (const ch of json.chapters) {
|
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
// Upload and open the sample EPUB in the viewer
|
||||||
}
|
await uploadAndDisplay(page, 'sample.epub');
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
// URL should now be /epub/[id]
|
||||||
});
|
const bookId = await getBookIdFromUrl(page, 'epub');
|
||||||
|
await resetAudiobookById(page, bookId);
|
||||||
test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => {
|
|
||||||
await setupTest(page);
|
// Open the audiobook export modal from the header button
|
||||||
|
await openExportModal(page);
|
||||||
// Upload and open the sample EPUB in the viewer
|
|
||||||
await uploadAndDisplay(page, 'sample.epub');
|
// Set container format to MP3
|
||||||
|
await setContainerFormatToMP3(page);
|
||||||
// URL should now be /epub/[id]
|
|
||||||
const bookId = await getBookIdFromUrl(page, 'epub');
|
// Start generation
|
||||||
await resetAudiobookById(page, bookId);
|
await startGeneration(page);
|
||||||
|
|
||||||
// Open the audiobook export modal from the header button
|
// Progress card should appear with a Cancel button while chapters are being generated
|
||||||
await openExportModal(page);
|
const cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||||
|
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
|
||||||
// Set container format to MP3
|
|
||||||
await setContainerFormatToMP3(page);
|
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
// Start generation
|
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
|
||||||
await startGeneration(page);
|
// moment we decide to cancel, and assert that no additional chapters are added afterward.
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
// Progress card should appear with a Cancel button while chapters are being generated
|
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
|
||||||
const cancelButton = page.getByRole('button', { name: 'Cancel' });
|
const chapterCountBeforeCancel = await chapterActionsButtons.count();
|
||||||
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
|
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
|
// Now cancel the in-flight generation
|
||||||
|
await cancelButton.click();
|
||||||
// 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.
|
// After cancellation, the inline progress card's Cancel button should be gone
|
||||||
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
||||||
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
|
|
||||||
const chapterCountBeforeCancel = await chapterActionsButtons.count();
|
// After cancellation, wait for the chapter count to stabilize. In-flight TTS
|
||||||
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
|
// requests may still complete after we click cancel, so we poll until the
|
||||||
|
// count stops changing for a brief period.
|
||||||
// Now cancel the in-flight generation
|
const { count: chapterCountAfterCancel, json: jsonAfterCancel } = await waitForStableChapterCount(
|
||||||
await cancelButton.click();
|
page,
|
||||||
|
bookId,
|
||||||
// After cancellation, the inline progress card's Cancel button should be gone
|
{ stableMs: 2000, timeoutMs: 30000 }
|
||||||
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
);
|
||||||
|
expect(jsonAfterCancel.exists).toBe(true);
|
||||||
// After cancellation, determine the canonical chapter count from the backend and
|
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
|
||||||
// assert that the UI eventually reflects this count. Some in-flight chapters may
|
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
|
||||||
// complete right as we cancel, so we treat the backend state as source of truth.
|
|
||||||
const jsonAfterCancel = await expectChaptersBackendState(page, bookId);
|
// Wait for the UI to reflect the final backend chapter count to avoid race
|
||||||
expect(jsonAfterCancel.exists).toBe(true);
|
// conditions between the modal's soft refresh and our assertions.
|
||||||
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
|
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
|
||||||
const chapterCountAfterCancel = jsonAfterCancel.chapters.length;
|
|
||||||
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
|
// The Full Download button should still be available for the partially generated audiobook
|
||||||
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
// Wait for the UI to reflect the final backend chapter count to avoid race
|
|
||||||
// conditions between the modal's soft refresh and our assertions.
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
|
expect(durationSeconds).toBeGreaterThan(25);
|
||||||
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
// The Full Download button should still be available for the partially generated audiobook
|
|
||||||
const downloadedPath = await downloadFullAudiobook(page);
|
// Backend should still reflect the same number of chapters as when we first
|
||||||
|
// observed the stabilized post-cancellation state.
|
||||||
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
expect(durationSeconds).toBeGreaterThan(25);
|
expect(json.exists).toBe(true);
|
||||||
expect(durationSeconds).toBeLessThan(300);
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(chapterCountAfterCancel);
|
||||||
// Backend should still reflect the same number of chapters as when we first
|
|
||||||
// observed the stabilized post-cancellation state, and should not contain
|
await resetAudiobookIfPresent(page);
|
||||||
// additional "impartial" chapters produced after cancellation.
|
});
|
||||||
const json = await expectChaptersBackendState(page, bookId);
|
|
||||||
expect(json.exists).toBe(true);
|
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }) => {
|
||||||
expect(Array.isArray(json.chapters)).toBe(true);
|
await setupTest(page);
|
||||||
expect(json.chapters.length).toBe(chapterCountAfterCancel);
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
});
|
await resetAudiobookById(page, bookId);
|
||||||
|
|
||||||
test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => {
|
await openExportModal(page);
|
||||||
await setupTest(page);
|
await setContainerFormatToMP3(page);
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
await startGeneration(page);
|
||||||
|
|
||||||
const bookId = await getBookIdFromUrl(page, 'pdf');
|
await waitForChaptersHeading(page);
|
||||||
await resetAudiobookById(page, bookId);
|
|
||||||
|
// Wait for at least one chapter row to appear (one "Chapter actions" button)
|
||||||
await openExportModal(page);
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
await setContainerFormatToMP3(page);
|
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
|
||||||
await startGeneration(page);
|
|
||||||
|
// Download via frontend button
|
||||||
await waitForChaptersHeading(page);
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
|
|
||||||
// Wait for at least one chapter row to appear (one "Chapter actions" button)
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
|
||||||
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
|
expect(durationSeconds).toBeGreaterThan(9);
|
||||||
|
expect(durationSeconds).toBeLessThan(300);
|
||||||
// Download via frontend button
|
|
||||||
const downloadedPath = await downloadFullAudiobook(page);
|
await resetAudiobookIfPresent(page);
|
||||||
|
});
|
||||||
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
|
||||||
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
|
test('resets all MP3 audiobook PDF pages', async ({ page }) => {
|
||||||
expect(durationSeconds).toBeGreaterThan(9);
|
await setupTest(page);
|
||||||
expect(durationSeconds).toBeLessThan(300);
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
});
|
await resetAudiobookById(page, bookId);
|
||||||
|
|
||||||
test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => {
|
await openExportModal(page);
|
||||||
await setupTest(page);
|
await setContainerFormatToMP3(page);
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
await startGeneration(page);
|
||||||
|
|
||||||
const bookId = await getBookIdFromUrl(page, 'pdf');
|
await waitForChaptersHeading(page);
|
||||||
await resetAudiobookById(page, bookId);
|
|
||||||
|
// Wait for Reset button to become visible, indicating resumable/generated state
|
||||||
await openExportModal(page);
|
const resetButton = page.getByRole('button', { name: 'Reset' });
|
||||||
await setContainerFormatToMP3(page);
|
await expect(resetButton).toBeVisible({ timeout: 120_000 });
|
||||||
await startGeneration(page);
|
|
||||||
|
await resetButton.click();
|
||||||
await waitForChaptersHeading(page);
|
|
||||||
|
// Confirm in the Reset Audiobook dialog
|
||||||
// Wait for Reset button to become visible, indicating resumable/generated state
|
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
|
||||||
const resetButton = page.getByRole('button', { name: 'Reset' });
|
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
||||||
await expect(resetButton).toBeVisible({ timeout: 120_000 });
|
await confirmReset.click();
|
||||||
|
|
||||||
await resetButton.click();
|
// After reset, generation should be startable again
|
||||||
|
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
||||||
// Confirm in the Reset Audiobook dialog
|
|
||||||
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
|
// Backend should report no existing chapters for this bookId
|
||||||
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
|
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
|
||||||
await confirmReset.click();
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const json = await res.json();
|
||||||
// After reset, generation should be startable again
|
expect(json.exists).toBe(false);
|
||||||
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
|
expect(json.chapters.length).toBe(0);
|
||||||
// Backend should report no existing chapters for this bookId
|
});
|
||||||
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
|
|
||||||
expect(res.ok()).toBeTruthy();
|
test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }) => {
|
||||||
const json = await res.json();
|
await setupTest(page);
|
||||||
expect(json.exists).toBe(false);
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
expect(Array.isArray(json.chapters)).toBe(true);
|
|
||||||
expect(json.chapters.length).toBe(0);
|
// Extract bookId from /pdf/[id] URL (for backend verification later)
|
||||||
});
|
const bookId = await getBookIdFromUrl(page, 'pdf');
|
||||||
|
await resetAudiobookById(page, bookId);
|
||||||
test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => {
|
|
||||||
await setupTest(page);
|
// Open Export Audiobook modal
|
||||||
await uploadAndDisplay(page, 'sample.pdf');
|
await openExportModal(page);
|
||||||
|
|
||||||
// Extract bookId from /pdf/[id] URL (for backend verification later)
|
// Set container format to MP3
|
||||||
const bookId = await getBookIdFromUrl(page, 'pdf');
|
await setContainerFormatToMP3(page);
|
||||||
await resetAudiobookById(page, bookId);
|
|
||||||
|
// Start generation
|
||||||
// Open Export Audiobook modal
|
await startGeneration(page);
|
||||||
await openExportModal(page);
|
|
||||||
|
// Wait for chapters to appear
|
||||||
// Set container format to MP3
|
await waitForChaptersHeading(page);
|
||||||
await setContainerFormatToMP3(page);
|
|
||||||
|
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
||||||
// Start generation
|
// Ensure we have at least two chapters for this PDF
|
||||||
await startGeneration(page);
|
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
|
||||||
|
const chapterCountBefore = await chapterActionsButtons.count();
|
||||||
// Wait for chapters to appear
|
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
|
||||||
await waitForChaptersHeading(page);
|
|
||||||
|
// Open the actions menu for the first chapter and trigger Regenerate
|
||||||
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
|
const firstChapterActions = chapterActionsButtons.first();
|
||||||
// Ensure we have at least two chapters for this PDF
|
await firstChapterActions.click();
|
||||||
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
|
|
||||||
const chapterCountBefore = await chapterActionsButtons.count();
|
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
|
||||||
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
|
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
|
||||||
|
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
|
||||||
// Open the actions menu for the first chapter and trigger Regenerate
|
await regenerateMenuItem.click();
|
||||||
const firstChapterActions = chapterActionsButtons.first();
|
|
||||||
await firstChapterActions.click();
|
// During regeneration, the row may show a "Regenerating" label; wait for any such
|
||||||
|
// indicator to disappear, signaling completion.
|
||||||
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
|
const regeneratingLabel = page.getByText(/Regenerating/);
|
||||||
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
|
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
|
||||||
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
|
|
||||||
await regenerateMenuItem.click();
|
// 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.
|
||||||
// During regeneration, the row may show a "Regenerating" label; wait for any such
|
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
|
||||||
// indicator to disappear, signaling completion.
|
expect(backendStateAfterRegenerate.exists).toBe(true);
|
||||||
const regeneratingLabel = page.getByText(/Regenerating/);
|
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
|
||||||
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
|
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
|
||||||
|
for (const ch of backendStateAfterRegenerate.chapters) {
|
||||||
// After regeneration completes in the UI, verify backend chapter state is fully updated
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
|
}
|
||||||
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
|
|
||||||
expect(backendStateAfterRegenerate.exists).toBe(true);
|
// Chapter count should remain exactly the same after regeneration (no duplicates)
|
||||||
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
|
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
|
||||||
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
|
|
||||||
for (const ch of backendStateAfterRegenerate.chapters) {
|
// Full Download should still work and produce a valid combined audiobook
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
const downloadedPath = await downloadFullAudiobook(page);
|
||||||
}
|
|
||||||
|
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
||||||
// Chapter count should remain exactly the same after regeneration (no duplicates)
|
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
|
||||||
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
|
expect(durationSeconds).toBeGreaterThan(18);
|
||||||
|
expect(durationSeconds).toBeLessThan(22);
|
||||||
// Full Download should still work and produce a valid combined audiobook
|
|
||||||
const downloadedPath = await downloadFullAudiobook(page);
|
// Backend should still report the same number of chapters and valid durations
|
||||||
|
const json = await expectChaptersBackendState(page, bookId);
|
||||||
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
|
expect(json.exists).toBe(true);
|
||||||
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
|
expect(Array.isArray(json.chapters)).toBe(true);
|
||||||
expect(durationSeconds).toBeGreaterThan(18);
|
expect(json.chapters.length).toBe(chapterCountBefore);
|
||||||
expect(durationSeconds).toBeLessThan(22);
|
for (const ch of json.chapters) {
|
||||||
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
// Backend should still report the same number of chapters and valid durations
|
}
|
||||||
const json = await expectChaptersBackendState(page, bookId);
|
|
||||||
expect(json.exists).toBe(true);
|
await resetAudiobookIfPresent(page);
|
||||||
expect(Array.isArray(json.chapters)).toBe(true);
|
});
|
||||||
expect(json.chapters.length).toBe(chapterCountBefore);
|
|
||||||
for (const ch of json.chapters) {
|
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }) => {
|
||||||
expect(ch.duration).toBeGreaterThan(0);
|
await setupTest(page);
|
||||||
}
|
await uploadAndDisplay(page, 'sample.pdf');
|
||||||
|
|
||||||
await resetAudiobookIfPresent(page);
|
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');
|
const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3');
|
||||||
let ttsMockBuffer: Buffer | null = null;
|
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) {
|
async function ensureTtsRouteMock(page: Page) {
|
||||||
if (!ttsMockBuffer) {
|
if (!ttsMockBuffer) {
|
||||||
ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);
|
ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue