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:
Richard R 2026-02-03 12:17:06 -07:00
parent 30ce65e747
commit c24710b2ca
25 changed files with 1369 additions and 1863 deletions

View file

@ -15,25 +15,27 @@ CREATE TABLE "account" (
);
--> statement-breakpoint
CREATE TABLE "audiobook_chapters" (
"id" text PRIMARY KEY NOT NULL,
"id" text NOT NULL,
"book_id" text NOT NULL,
"user_id" text,
"user_id" text NOT NULL,
"chapter_index" integer NOT NULL,
"title" text NOT NULL,
"duration" real DEFAULT 0,
"file_path" text NOT NULL,
"format" text NOT NULL
"format" text NOT NULL,
CONSTRAINT "audiobook_chapters_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "audiobooks" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"id" text NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"author" text,
"description" text,
"cover_path" text,
"duration" real DEFAULT 0,
"created_at" timestamp DEFAULT now()
"created_at" timestamp DEFAULT now(),
CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "documents" (
@ -88,7 +90,6 @@ CREATE TABLE "verification" (
"updated_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_audiobooks_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."audiobooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date");
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date");

View file

@ -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;

View file

@ -1,5 +1,5 @@
{
"id": "5ac6552c-824b-4b79-8cb6-f0ec3d121031",
"id": "63a00f5b-69ff-4d95-a48a-9e84cc331445",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@ -99,7 +99,7 @@
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@ -116,7 +116,7 @@
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"primaryKey": false,
"notNull": true
},
"book_id": {
@ -129,7 +129,7 @@
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"chapter_index": {
"name": "chapter_index",
@ -164,22 +164,16 @@
}
},
"indexes": {},
"foreignKeys": {
"audiobook_chapters_book_id_audiobooks_id_fk": {
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
"foreignKeys": {},
"compositePrimaryKeys": {
"audiobook_chapters_id_user_id_pk": {
"name": "audiobook_chapters_id_user_id_pk",
"columns": [
"id",
"user_id"
]
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
@ -192,14 +186,14 @@
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
"notNull": true
},
"title": {
"name": "title",
@ -242,7 +236,15 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"audiobooks_id_user_id_pk": {
"name": "audiobooks_id_user_id_pk",
"columns": [
"id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
@ -383,7 +385,7 @@
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@ -589,4 +591,4 @@
"schemas": {},
"tables": {}
}
}
}

View file

@ -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": {}
}
}

View file

@ -5,16 +5,9 @@
{
"idx": 0,
"version": "7",
"when": 1769393893675,
"tag": "0000_military_nemesis",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1769404444945,
"tag": "0001_futuristic_harpoon",
"when": 1769641576464,
"tag": "0000_lucky_zarek",
"breakpoints": true
}
]
}
}

View file

@ -12,30 +12,31 @@ CREATE TABLE `account` (
`password` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audiobook_chapters` (
`id` text PRIMARY KEY NOT NULL,
`id` text NOT NULL,
`book_id` text NOT NULL,
`user_id` text,
`user_id` text NOT NULL,
`chapter_index` integer NOT NULL,
`title` text NOT NULL,
`duration` real DEFAULT 0,
`file_path` text NOT NULL,
`format` text NOT NULL,
FOREIGN KEY (`book_id`) REFERENCES `audiobooks`(`id`) ON UPDATE no action ON DELETE cascade
PRIMARY KEY(`id`, `user_id`)
);
--> statement-breakpoint
CREATE TABLE `audiobooks` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`id` text NOT NULL,
`user_id` text NOT NULL,
`title` text NOT NULL,
`author` text,
`description` text,
`cover_path` text,
`duration` real DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000)
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`id`, `user_id`)
);
--> statement-breakpoint
CREATE TABLE `documents` (
@ -59,7 +60,7 @@ CREATE TABLE `session` (
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint

View file

@ -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`);

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f5c06478-a702-4809-ab9b-3a63f11b8b0c",
"id": "af57dff3-a6ec-418e-9fa1-8197d6839e48",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@ -111,7 +111,7 @@
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@ -125,7 +125,7 @@
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@ -140,7 +140,7 @@
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"chapter_index": {
@ -181,22 +181,16 @@
}
},
"indexes": {},
"foreignKeys": {
"audiobook_chapters_book_id_audiobooks_id_fk": {
"name": "audiobook_chapters_book_id_audiobooks_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id"
"foreignKeys": {},
"compositePrimaryKeys": {
"audiobook_chapters_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
"name": "audiobook_chapters_id_user_id_pk"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
@ -206,7 +200,7 @@
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@ -214,7 +208,7 @@
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
},
"title": {
@ -264,7 +258,15 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"audiobooks_id_user_id_pk": {
"columns": [
"id",
"user_id"
],
"name": "audiobooks_id_user_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
@ -423,7 +425,7 @@
"columnsTo": [
"id"
],
"onDelete": "no action",
"onDelete": "cascade",
"onUpdate": "no action"
}
},
@ -618,4 +620,4 @@
"internal": {
"indexes": {}
}
}
}

View file

@ -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": {}
}
}

View file

@ -5,16 +5,9 @@
{
"idx": 0,
"version": "6",
"when": 1769395269830,
"tag": "0000_hard_mandarin",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769535439728,
"tag": "0001_violet_mantis",
"when": 1769641566451,
"tag": "0000_steep_shaman",
"breakpoints": true
}
]
}
}

View file

@ -26,7 +26,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run build && npm run start',
// Disable auth rate limiting for tests to support parallel workers creating sessions
command: 'npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start',
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,

View file

@ -1,21 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { createReadStream, existsSync } from 'fs';
import { readdir, unlink } from 'fs/promises';
import { join } from 'path';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { join, resolve } from 'path';
import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, UNCLAIMED_USER_ID, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { findStoredChapterByIndex } from '@/lib/server/audiobook';
import { db } from '@/db';
import { audiobookChapters } from '@/db/schema';
import { and, eq } from 'drizzle-orm';
import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { and, eq, or } from 'drizzle-orm';
import { requireAuthContext } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
/**
* Get the base audiobooks directory, accounting for test namespaces.
* When auth is disabled, returns AUDIOBOOKS_V1_DIR.
* When auth is enabled, returns the user-specific directory.
*/
function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
const getTestNamespacePath = (baseDir: string): string => {
if (!raw) return baseDir;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
return baseDir;
}
const resolved = resolve(baseDir, safe);
if (!resolved.startsWith(resolve(baseDir) + '/')) {
return baseDir;
}
return resolved;
};
if (!authEnabled || !userId) {
return getTestNamespacePath(AUDIOBOOKS_V1_DIR);
}
const userDir = getUserAudiobookDir(userId);
return getTestNamespacePath(userDir);
}
export async function GET(request: NextRequest) {
@ -47,12 +69,31 @@ export async function GET(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
if (denied) return denied;
const { userId, authEnabled } = ctxOrRes;
// Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks
if (authEnabled && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(
eq(audiobooks.id, bookId),
or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID))
)
);
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// Get the audiobook directory - check user's directory first, then unclaimed
let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`);
// If not found in user's directory and auth is enabled, check unclaimed directory
if (!existsSync(intermediateDir) && authEnabled && userId) {
const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`);
if (existsSync(unclaimedDir)) {
intermediateDir = unclaimedDir;
}
}
const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal);
if (!chapter || !existsSync(chapter.filePath)) {
@ -128,20 +169,28 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'forbidden' });
if (denied) return denied;
// Delete from DB first
if (db) {
await db.delete(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.chapterIndex, chapterIndex)),
);
// Verify ownership and delete from DB with composite PK
if (authEnabled && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
);
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
// Delete from DB
await db.delete(audiobookChapters).where(
and(
eq(audiobookChapters.bookId, bookId),
eq(audiobookChapters.userId, userId),
eq(audiobookChapters.chapterIndex, chapterIndex)
),
);
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
const files = await readdir(intermediateDir).catch(() => []);
for (const file of files) {
@ -167,4 +216,3 @@ export async function DELETE(request: NextRequest) {
);
}
}

View file

@ -4,32 +4,51 @@ import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/prom
import { existsSync, createReadStream } from 'fs';
import { basename, join, resolve } from 'path';
import { randomUUID } from 'crypto';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore';
import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { eq, and, or } from 'drizzle-orm';
import { isAuthEnabled } from '@/lib/server/auth-config';
import { getAuthContext, requireAuthContext } from '@/lib/server/auth';
import { requireAuthContext } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
/**
* Apply test namespace to a directory path if present in request headers.
*/
function applyTestNamespace(baseDir: string, request: NextRequest): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
if (!raw) return baseDir;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
return AUDIOBOOKS_V1_DIR;
return baseDir;
}
const resolved = resolve(AUDIOBOOKS_V1_DIR, safe);
if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) {
return AUDIOBOOKS_V1_DIR;
const resolved = resolve(baseDir, safe);
if (!resolved.startsWith(resolve(baseDir) + '/')) {
return baseDir;
}
return resolved;
}
/**
* Get the base audiobooks directory, accounting for test namespaces.
* When auth is disabled, returns AUDIOBOOKS_V1_DIR (possibly with test namespace).
* When auth is enabled, returns the user-specific directory under AUDIOBOOKS_USERS_DIR.
*/
function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string {
// When auth is disabled, use the flat audiobooks_v1 directory
if (!authEnabled || !userId) {
return applyTestNamespace(AUDIOBOOKS_V1_DIR, request);
}
// When auth is enabled, use user-specific directory
const userDir = getUserAudiobookDir(userId);
return applyTestNamespace(userDir, request);
}
interface ConversionRequest {
chapterTitle: string;
buffer: TTSAudioBytes;
@ -183,8 +202,10 @@ export async function POST(request: NextRequest) {
}
// DB Check / Insert Audiobook
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
if (isAuthEnabled() && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
);
if (!existingBook) {
await db.insert(audiobooks).values({
@ -192,14 +213,10 @@ export async function POST(request: NextRequest) {
userId,
title: data.chapterTitle || 'Untitled Audiobook',
});
} else {
if (existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const intermediateDir = join(getAudiobooksRootDir(request, userId, ctxOrRes.authEnabled), `${bookId}-audiobook`);
// Create intermediate directory
await mkdir(intermediateDir, { recursive: true });
@ -346,7 +363,7 @@ export async function POST(request: NextRequest) {
await unlink(inputPath).catch(console.error);
// Insert Chapter Record (Denormalized)
if (isAuthEnabled() && db) {
if (isAuthEnabled() && db && userId) {
await db.insert(audiobookChapters).values({
id: `${bookId}-${chapterIndex}`,
bookId,
@ -357,7 +374,7 @@ export async function POST(request: NextRequest) {
format,
filePath: finalChapterName
}).onConflictDoUpdate({
target: [audiobookChapters.id],
target: [audiobookChapters.id, audiobookChapters.userId],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName }
});
}
@ -404,17 +421,33 @@ export async function GET(request: NextRequest) {
);
}
const { userId } = await getAuthContext(request);
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes;
// Verify ownership
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
if (existingBook && existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 }); // Hide existence
// Check if audiobook exists for user OR is unclaimed (similar to documents)
if (authEnabled && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(
eq(audiobooks.id, bookId),
or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID))
)
);
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// Get the audiobook directory - check user's directory first, then unclaimed
let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`);
// If not found in user's directory and auth is enabled, check unclaimed directory
if (!existsSync(intermediateDir) && authEnabled && userId) {
const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`);
if (existsSync(unclaimedDir)) {
intermediateDir = unclaimedDir;
}
}
if (!existsSync(intermediateDir)) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
@ -610,24 +643,29 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
const { userId, authEnabled } = ctxOrRes;
// Verify ownership
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
// Delete from DB - with composite PK, we delete by both id and userId
if (authEnabled && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
);
if (existingBook) {
if (existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.delete(audiobooks).where(eq(audiobooks.id, bookId));
} else {
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
// Delete chapters first (no foreign key constraint with composite PK)
await db.delete(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId))
);
await db.delete(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))
);
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`);
// If directory doesn't exist, consider it already reset
if (!existsSync(intermediateDir)) {

View file

@ -1,20 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { existsSync } from 'fs';
import { join } from 'path';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { join, resolve } from 'path';
import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUserAudiobookDir, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { listStoredChapters } from '@/lib/server/audiobook';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
import { readFile } from 'fs/promises';
import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth';
import { requireAuthContext } from '@/lib/server/auth';
import { db } from '@/db';
import { audiobooks } from '@/db/schema';
import { eq, and, or } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
function getAudiobooksRootDir(request: NextRequest): string {
/**
* Get the base audiobooks directory, accounting for test namespaces.
* When auth is disabled, returns AUDIOBOOKS_V1_DIR.
* When auth is enabled, returns the user-specific directory.
*/
function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string {
const raw = request.headers.get('x-openreader-test-namespace')?.trim();
if (!raw) return AUDIOBOOKS_V1_DIR;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR;
const applyTestNamespace = (baseDir: string): string => {
if (!raw) return baseDir;
const safe = raw.replace(/[^a-zA-Z0-9._-]/g, '');
if (!safe || safe === '.' || safe === '..' || safe.includes('..')) {
return baseDir;
}
const resolved = resolve(baseDir, safe);
if (!resolved.startsWith(resolve(baseDir) + '/')) {
return baseDir;
}
return resolved;
};
if (!authEnabled || !userId) {
return applyTestNamespace(AUDIOBOOKS_V1_DIR);
}
const userDir = getUserAudiobookDir(userId);
return applyTestNamespace(userDir);
}
const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
@ -40,9 +65,18 @@ export async function GET(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
if (denied) {
const { userId, authEnabled } = ctxOrRes;
// Check if audiobook exists for user OR is unclaimed (similar to documents)
if (authEnabled && db && userId) {
const [existingBook] = await db.select().from(audiobooks).where(
and(
eq(audiobooks.id, bookId),
or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID))
)
);
if (!existingBook) {
// Book doesn't exist for this user or unclaimed - return empty state
return NextResponse.json({
chapters: [],
exists: false,
@ -53,7 +87,16 @@ export async function GET(request: NextRequest) {
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// Get the audiobook directory - check user's directory first, then unclaimed
let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`);
// If not found in user's directory and auth is enabled, check unclaimed directory
if (!existsSync(intermediateDir) && authEnabled && userId) {
const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`);
if (existsSync(unclaimedDir)) {
intermediateDir = unclaimedDir;
}
}
if (!existsSync(intermediateDir)) {
return NextResponse.json({

View file

@ -1,15 +1,97 @@
'use client';
import { useEffect, useRef, useState, ReactNode } from 'react';
import type { BetterFetchError } from 'better-auth/react';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/auth-client';
import { LoadingSpinner } from '@/components/Spinner';
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
type ErrorInfo = { status?: number; message?: string };
function isBetterFetchError(input: unknown): input is BetterFetchError {
if (!input || typeof input !== 'object') return false;
const rec = input as Record<string, unknown>;
return (
input instanceof Error &&
typeof rec.status === 'number' &&
typeof rec.statusText === 'string' &&
'error' in rec
);
}
/**
* Normalize different error shapes into a single `{ status, message }`.
*
* Handles:
* - thrown Errors that may include `status`
* - better-auth / better-fetch style endpoint returns: `{ data, error }`
*/
function getErrorInfo(input: unknown): ErrorInfo | null {
if (!input) return null;
if (isBetterFetchError(input)) {
return {
status: input.status,
message: input.statusText || input.message,
};
}
if (input instanceof Error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const status = typeof (input as any).status === 'number' ? (input as any).status : undefined;
return { status, message: input.message };
}
// Handle better-fetch style: { error: { status, message } } OR { error: string }
if (typeof input === 'object') {
const rec = input as Record<string, unknown>;
if ('error' in rec && rec.error) {
const err = rec.error;
if (isBetterFetchError(err)) {
return {
status: err.status,
message: err.statusText || err.message,
};
}
if (typeof err === 'object' && err !== null) {
const e = err as Record<string, unknown>;
const status = typeof e.status === 'number' ? e.status : undefined;
const message = typeof e.message === 'string' ? e.message : undefined;
return { status, message };
}
return { message: typeof err === 'string' ? err : 'Request failed' };
}
const status = rec.status;
const message = rec.message;
const out: ErrorInfo = {};
if (typeof status === 'number') out.status = status;
if (typeof message === 'string') out.message = message;
if (out.status !== undefined || out.message !== undefined) return out;
}
if (typeof input === 'string') return { message: input };
return null;
}
function isRateLimited(info: ErrorInfo | null): boolean {
if (!info) return false;
if (info.status === 429) return true;
// Fallback for cases where the server didn't return a numeric status.
const msg = info.message || '';
return /too\s+many\s+requests|rate\s*limit/i.test(msg);
}
export function AuthLoader({ children }: { children: ReactNode }) {
const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAuthRateLimit();
const { data: session, isPending } = useAuthSession();
const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession();
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
const [retryNonce, setRetryNonce] = useState(0);
@ -43,18 +125,78 @@ export function AuthLoader({ children }: { children: ReactNode }) {
try {
const client = getAuthClient(baseUrl);
await client.signIn.anonymous();
// In Playwright/`next start` we sometimes hit 429s on anonymous sign-in.
// Keep using better-auth client so its session hook updates correctly,
// but add retry/backoff around the call.
const maxAttempts = 6;
const baseDelayMs = 500;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const attemptTag = `[AuthLoader] better-auth signIn.anonymous attempt ${attempt}/${maxAttempts}`;
try {
console.info(attemptTag);
const result = await client.signIn.anonymous();
const info = getErrorInfo(result);
if (info) {
// better-auth client endpoints often do NOT throw; they return { data, error }.
// Convert that into a thrown error so our retry/backoff logic works.
const e = new Error(info.message || 'Anonymous sign-in failed');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(e as any).status = info.status;
console.warn(`${attemptTag} (non-throwing error response)`, info);
throw e;
}
console.info(`${attemptTag} (success)`, result ? { hasResult: true } : { hasResult: false });
// In some environments (notably Playwright against `next start`),
// the session signal does not immediately update after setting the
// session cookie. Force an explicit session refetch.
if (typeof refetchSession === 'function') {
console.info('[AuthLoader] refetching session after anonymous sign-in');
await refetchSession();
}
// Give React a moment to observe the updated session.
await sleep(50);
break;
} catch (err) {
const info = getErrorInfo(err);
console.warn(`${attemptTag} (failed)`, info ?? undefined);
console.warn(err);
if (isRateLimited(info) && attempt < maxAttempts) {
// better-auth doesn't currently expose Retry-After headers here;
// fall back to exponential backoff.
const delayMs = Math.min(10_000, baseDelayMs * Math.pow(2, attempt - 1));
console.warn(`${attemptTag} rate-limited; waiting ${delayMs}ms before retry`);
await sleep(delayMs);
continue;
}
throw err;
}
}
await refreshRateLimit();
} catch (err) {
console.error('Auto-login failed', err);
setBootstrapError('Unable to start an anonymous session.');
console.error('[AuthLoader] auto-login failed', err);
setBootstrapError('Unable to start an anonymous session (rate limited or network error).');
} finally {
setIsAutoLoggingIn(false);
}
};
checkStatus();
}, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]);
}, [session, isPending, authEnabled, baseUrl, refreshRateLimit, refetchSession, retryNonce]);
useEffect(() => {
if (!authEnabled) return;
if (sessionError) {
console.warn('[AuthLoader] useSession error', sessionError);
}
}, [authEnabled, sessionError]);
// Show loader if:
// 1. Auth client is initializing (isPending) AND auth is enabled

View file

@ -100,6 +100,10 @@ interface SetTextOptions {
const CONTINUATION_LOOKAHEAD = 600;
const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/;
// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari.
const SILENT_WAV_DATA_URI =
'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
const normalizeLocationKey = (location: TTSLocation) =>
typeof location === 'number' ? `num:${location}` : `str:${location}`;
@ -367,6 +371,109 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const sentencesRef = useRef<string[]>([]);
const currentIndexRef = useRef(0);
const audioUnlockAttemptRef = useRef(0);
// Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying
// the desired rate while a sentence is playing.
const rateWatchdogIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const clearRateWatchdog = useCallback(() => {
if (!rateWatchdogIntervalRef.current) return;
clearInterval(rateWatchdogIntervalRef.current);
rateWatchdogIntervalRef.current = null;
}, []);
const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => {
if (!howl) return;
try {
howl.rate(audioSpeed);
} catch {
// ignore
}
// Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly.
// This helps on browsers that reset playbackRate/defaultPlaybackRate.
try {
const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds;
const node = sounds?.[0]?._node as unknown;
if (node && typeof node === 'object') {
const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number };
if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed;
if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed;
}
} catch {
// ignore
}
}, [audioSpeed]);
const startRateWatchdog = useCallback((howl: Howl | null) => {
if (!howl) return;
clearRateWatchdog();
// Apply immediately + keep applying while playback is active.
applyPlaybackRateToHowl(howl);
rateWatchdogIntervalRef.current = setInterval(() => {
applyPlaybackRateToHowl(howl);
}, 250);
}, [applyPlaybackRateToHowl, clearRateWatchdog]);
const unlockPlaybackOnUserGesture = useCallback(() => {
// Best-effort; safe to call multiple times.
audioUnlockAttemptRef.current += 1;
const attempt = audioUnlockAttemptRef.current;
try {
void audioContext?.resume();
} catch {
// ignore
}
try {
const el = new Audio(SILENT_WAV_DATA_URI);
try {
el.setAttribute('playsinline', 'true');
} catch {
// ignore
}
el.preload = 'auto';
el.volume = 0;
const p = el.play();
if (p && typeof (p as Promise<void>).then === 'function') {
void (p as Promise<void>)
.then(() => {
if (audioUnlockAttemptRef.current !== attempt) return;
try {
el.pause();
el.currentTime = 0;
} catch {
// ignore
}
})
.catch(() => {
// ignore
});
}
} catch {
// ignore
}
}, [audioContext]);
const isAutoplayBlockedError = useCallback((err: unknown) => {
const msg = (() => {
if (typeof err === 'string') return err;
if (err instanceof Error) return err.message;
if (typeof err === 'object' && err !== null && 'message' in err) {
const maybe = (err as { message?: unknown }).message;
if (typeof maybe === 'string') return maybe;
}
return '';
})();
return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg);
}, []);
useEffect(() => {
sentencesRef.current = sentences;
}, [sentences]);
@ -395,6 +502,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* @param {boolean} [clearPending=false] - Whether to clear pending requests
*/
const abortAudio = useCallback((clearPending = false) => {
clearRateWatchdog();
if (activeHowl) {
activeHowl.stop();
activeHowl.unload();
@ -414,7 +522,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
pageTurnTimeoutRef.current = null;
}
setCurrentWordIndex(null);
}, [activeHowl]);
}, [activeHowl, clearRateWatchdog]);
/**
* Pauses the current audio playback
@ -666,15 +774,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* Toggles the playback state between playing and paused
*/
const togglePlay = useCallback(() => {
setIsPlaying((prev) => {
if (!prev) {
return true;
} else {
abortAudio();
return false;
}
});
}, [abortAudio]);
if (isPlaying) {
abortAudio();
setIsPlaying(false);
return;
}
// Ensure audio is unlocked while we're still in the click/tap handler.
unlockPlaybackOnUserGesture();
setIsPlaying(true);
}, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]);
/**
@ -1068,6 +1177,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
pool: 1,
rate: audioSpeed,
onload: function (this: Howl) {
applyPlaybackRateToHowl(this);
const estimate = pageTurnEstimateRef.current;
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
if (!visualPageChangeHandlerRef.current) return;
@ -1089,14 +1199,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
visualPageChangeHandlerRef.current?.(currentEstimate.location);
}, delayMs);
},
onplay: () => {
onplay: function (this: Howl) {
setIsProcessing(false);
startRateWatchdog(this);
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
},
onplayerror: function (this: Howl, error) {
console.warn('Howl playback error:', error);
onplayerror: function (this: Howl, soundId, error) {
const actualError = error ?? soundId;
console.warn('Howl playback error:', actualError);
// Common on iOS/Safari when the actual play() call happens after awaiting TTS.
// Do not skip/advance in this case; just pause and tell the user to tap play again.
if (isAutoplayBlockedError(actualError)) {
setIsProcessing(false);
setActiveHowl(null);
try {
this.unload();
} catch {
// ignore unload errors
}
setIsPlaying(false);
toast.error('Playback was blocked by your browser. Tap play again to start.', {
id: 'tts-playback-blocked',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 4000,
});
return;
}
playErrorAttempts += 1;
// Avoid looping for many seconds on Safari: if playback still fails after a single
@ -1106,6 +1242,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setActiveHowl(null);
this.unload();
setIsPlaying(false);
toast.error('Audio playback failed. Skipped sentence and paused.', {
id: 'tts-playback-error',
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 4000,
});
advance();
return;
}
@ -1117,8 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
this.load();
}
},
onloaderror: async function (this: Howl, error) {
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error);
onloaderror: async function (this: Howl, soundId, error) {
const actualError = error ?? soundId;
console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError);
if (retryCount < MAX_RETRIES) {
// Calculate exponential backoff delay
@ -1184,6 +1331,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
},
onend: function (this: Howl) {
clearRateWatchdog();
this.unload();
setActiveHowl(null);
if (pageTurnTimeoutRef.current) {
@ -1195,6 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
},
onstop: function (this: Howl) {
clearRateWatchdog();
setIsProcessing(false);
this.unload();
}
@ -1225,7 +1374,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
advance();
return null;
}
}, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]);
}, [
abortAudio,
isPlaying,
advance,
activeHowl,
processSentence,
audioSpeed,
isAutoplayBlockedError,
applyPlaybackRateToHowl,
startRateWatchdog,
clearRateWatchdog,
]);
const playAudio = useCallback(async () => {
const sentence = sentences[currentIndex];
@ -1251,6 +1411,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}
}, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]);
// Keep the current playback rate applied to the active Howl. Some browsers (notably
// iOS Safari with HTML5 audio) can reset playbackRate after initial load/play.
useEffect(() => {
if (!activeHowl) return;
applyPlaybackRateToHowl(activeHowl);
if (isPlaying) {
startRateWatchdog(activeHowl);
}
}, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]);
// Place useBackgroundState after playAudio is defined
const isBackgrounded = useBackgroundState({
activeHowl,
@ -1408,9 +1578,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const stopAndPlayFromIndex = useCallback((index: number) => {
abortAudio();
// Same autoplay-unlock issue as togglePlay when starting from a fresh load.
unlockPlaybackOnUserGesture();
setCurrentIndex(index);
setIsPlaying(true);
}, [abortAudio]);
}, [abortAudio, unlockPlaybackOnUserGesture]);
/**
* Sets the speed and restarts the playback

View file

@ -14,26 +14,30 @@ export const documents = pgTable('documents', {
}));
export const audiobooks = pgTable('audiobooks', {
id: text('id').primaryKey(),
userId: text('user_id'),
id: text('id').notNull(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: timestamp('created_at').defaultNow(),
});
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const audiobookChapters = pgTable('audiobook_chapters', {
id: text('id').primaryKey(),
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
userId: text('user_id'),
id: text('id').notNull(),
bookId: text('book_id').notNull(),
userId: text('user_id').notNull(),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
filePath: text('file_path').notNull(),
format: text('format').notNull(), // mp3, m4b
});
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const user = pgTable("user", {
id: text("id").primaryKey(),

View file

@ -15,26 +15,30 @@ export const documents = sqliteTable('documents', {
}));
export const audiobooks = sqliteTable('audiobooks', {
id: text('id').primaryKey(),
userId: text('user_id'),
id: text('id').notNull(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
});
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const audiobookChapters = sqliteTable('audiobook_chapters', {
id: text('id').primaryKey(),
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
userId: text('user_id'),
id: text('id').notNull(),
bookId: text('book_id').notNull(),
userId: text('user_id').notNull(),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
filePath: text('file_path').notNull(),
format: text('format').notNull(), // mp3, m4b
});
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const user = sqliteTable("user", {
id: text("id").primaryKey(),

View file

@ -4,6 +4,8 @@ import { useMemo } from 'react';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { getAuthClient } from '@/lib/auth-client';
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['useSession']>;
/**
* Hook for session that uses the correct baseUrl from context
*/
@ -16,7 +18,18 @@ export function useAuthSession() {
}, [baseUrl, authEnabled]);
if (!client) {
return { data: null, isPending: false, error: null };
// Keep a stable shape so consumers can always destructure the same fields.
// This avoids union-type issues when auth is disabled.
const empty: SessionHookResult = {
data: null,
isPending: false,
isRefetching: false,
// better-auth types use BetterFetchError | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: null as any,
refetch: async () => {},
};
return empty;
}
return client.useSession();

View file

@ -7,9 +7,7 @@ import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled } from "@/lib/server/auth-config";
import { eq } from 'drizzle-orm';
import { transferUserAudiobooks } from "@/lib/server/claim-data";
import * as schema from "@/db/schema"; // Import the dynamic schema
@ -37,6 +35,11 @@ const createAuth = () => betterAuth({
console.log("Password reset requested for:", data.user.email);
},
},
rateLimit: {
// Disable rate limiting when running tests to support parallel test workers
// In production, better-auth's default rate limiting applies
enabled: process.env.DISABLE_AUTH_RATE_LIMIT !== 'true',
},
socialProviders: {
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && {
github: {
@ -72,6 +75,17 @@ const createAuth = () => betterAuth({
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring audiobooks during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process
@ -128,32 +142,4 @@ export async function requireAuthContext(
}
return ctx;
}
export type AudiobookAccessMode = 'forbidden' | 'notFound';
export async function requireAudiobookOwned(
bookId: string,
userId: string,
options?: { onDenied?: AudiobookAccessMode },
): Promise<Response | null> {
if (!isAuthEnabled() || !db) return null;
const [existingBook] = await db
.select({ userId: schema.audiobooks.userId })
.from(schema.audiobooks)
.where(eq(schema.audiobooks.id, bookId));
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
if (existingBook.userId && existingBook.userId !== userId) {
if (options?.onDenied === 'notFound') {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}

View file

@ -1,39 +1,45 @@
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
import { eq, isNull, count } from 'drizzle-orm';
import { eq, and, count } from 'drizzle-orm';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore';
import {
DOCUMENTS_V1_DIR,
AUDIOBOOKS_V1_DIR,
UNCLAIMED_USER_ID,
getUnclaimedAudiobookDir,
getUserAudiobookDir,
moveAudiobookToUser,
listUserAudiobookIds
} from './docstore';
import { listStoredChapters } from './audiobook';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Helper to check if a file is already indexed
// Helper to check if a document is already indexed
async function isDocumentIndexed(id: string) {
if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently?
// Actually if no DB, we don't index into DB. So returning false is fine,
// but scanAndPopulateDB should probably return early if no DB.
if (!isAuthEnabled() || !db) return false;
const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id));
return result.length > 0;
}
async function isAudiobookIndexed(id: string) {
// Helper to check if an audiobook is indexed for a specific user (composite PK)
async function isAudiobookIndexedForUser(id: string, userId: string) {
if (!isAuthEnabled() || !db) return false;
const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id));
const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(
and(eq(audiobooks.id, id), eq(audiobooks.userId, userId))
);
return result.length > 0;
}
const UNCLAIMED_ID = 'unclaimed';
/**
* Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed')
*/
export async function getUnclaimedCounts() {
if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 };
const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID));
const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID));
const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID));
const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID));
return {
documents: docCount?.count ?? 0,
@ -41,6 +47,41 @@ export async function getUnclaimedCounts() {
};
}
/**
* Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to the unclaimed user folder.
* This handles the case where audiobooks were created before the per-user storage refactor.
*/
async function migrateLegacyAudiobooksToUnclaimed(): Promise<number> {
if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0;
const unclaimedDir = getUnclaimedAudiobookDir();
await fs.mkdir(unclaimedDir, { recursive: true });
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
let migrated = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name);
const targetDir = path.join(unclaimedDir, entry.name);
// Skip if already exists in unclaimed
if (existsSync(targetDir)) continue;
try {
await fs.rename(sourceDir, targetDir);
migrated++;
console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`);
} catch (err) {
console.error(`Error migrating legacy audiobook ${entry.name}:`, err);
}
}
return migrated;
}
export async function scanAndPopulateDB() {
if (!isAuthEnabled() || !db) {
console.log('Skipping DB population (Auth/DB disabled)');
@ -49,19 +90,10 @@ export async function scanAndPopulateDB() {
console.log('Scanning file system for un-indexed content...');
// 0. Fix legacy NULL userIds
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId));
} catch (err) {
console.error('Error migrating legacy NULL userIds:', err);
}
// 0. Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to unclaimed folder
await migrateLegacyAudiobooksToUnclaimed();
// 1. Scan Documents
// 1. Scan Documents (unchanged - documents use shared storage)
if (existsSync(DOCUMENTS_V1_DIR)) {
const files = await fs.readdir(DOCUMENTS_V1_DIR);
for (const file of files) {
@ -86,7 +118,7 @@ export async function scanAndPopulateDB() {
await db.insert(documents).values({
id,
userId: UNCLAIMED_ID,
userId: UNCLAIMED_USER_ID,
name,
type: ext,
size: stats.size,
@ -97,17 +129,18 @@ export async function scanAndPopulateDB() {
}
}
// 2. Scan Audiobooks
if (existsSync(AUDIOBOOKS_V1_DIR)) {
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
// 2. Scan Audiobooks from unclaimed folder
const unclaimedDir = getUnclaimedAudiobookDir();
if (existsSync(unclaimedDir)) {
const entries = await fs.readdir(unclaimedDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.replace('-audiobook', '');
if (await isAudiobookIndexed(bookId)) continue;
if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue;
const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name);
const dirPath = path.join(unclaimedDir, entry.name);
let title = `Unknown Title`;
@ -126,7 +159,7 @@ export async function scanAndPopulateDB() {
await db.insert(audiobooks).values({
id: bookId,
userId: UNCLAIMED_ID,
userId: UNCLAIMED_USER_ID,
title: title,
duration: totalDuration,
});
@ -136,13 +169,13 @@ export async function scanAndPopulateDB() {
await db.insert(audiobookChapters).values({
id: `${bookId}-${chapter.index}`,
bookId: bookId,
userId: UNCLAIMED_ID,
userId: UNCLAIMED_USER_ID,
chapterIndex: chapter.index,
title: chapter.title,
duration: chapter.durationSec || 0,
filePath: chapter.filePath,
format: chapter.format
})
});
}
}
}
@ -154,25 +187,134 @@ export async function scanAndPopulateDB() {
export async function claimAnonymousData(userId: string) {
if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 };
// Update Documents
// Get list of unclaimed audiobook IDs before updating DB
const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID);
// Update Documents - documents use shared storage, only DB update needed
const docResult = await db.update(documents)
.set({ userId })
.where(eq(documents.userId, UNCLAIMED_ID))
.returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes
.where(eq(documents.userId, UNCLAIMED_USER_ID))
.returning({ id: documents.id });
// Update Audiobooks
const bookResult = await db.update(audiobooks)
.set({ userId })
.where(eq(audiobooks.userId, UNCLAIMED_ID))
.returning({ id: audiobooks.id });
// For audiobooks, we need to:
// 1. Move the physical folders from unclaimed to user's folder
// 2. Update the DB records
// Update Chapters (denormalized userId)
await db.update(audiobookChapters)
.set({ userId })
.where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join
let audiobooksClaimedCount = 0;
const userDir = getUserAudiobookDir(userId);
await fs.mkdir(userDir, { recursive: true });
for (const bookId of unclaimedBookIds) {
try {
// Move the audiobook folder
const moved = await moveAudiobookToUser(bookId, UNCLAIMED_USER_ID, userId);
if (moved) {
// Update DB - delete old record and insert new one (composite PK requires this)
const [oldRecord] = await db.select().from(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID))
);
if (oldRecord) {
// Get chapters
const oldChapters = await db.select().from(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID))
);
// Delete old records
await db.delete(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID))
);
await db.delete(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID))
);
// Insert new records with new userId
await db.insert(audiobooks).values({
...oldRecord,
userId,
});
for (const chapter of oldChapters) {
await db.insert(audiobookChapters).values({
...chapter,
userId,
});
}
audiobooksClaimedCount++;
console.log(`Claimed audiobook: ${bookId}`);
}
}
} catch (err) {
console.error(`Error claiming audiobook ${bookId}:`, err);
}
}
return {
documents: docResult.length,
audiobooks: bookResult.length
audiobooks: audiobooksClaimedCount
};
}
/**
* Transfer audiobooks from one user to another.
* Used when an anonymous user creates a real account.
* @returns number of audiobooks transferred
*/
export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise<number> {
if (!isAuthEnabled() || !db || !fromUserId || !toUserId) return 0;
const bookIds = await listUserAudiobookIds(fromUserId);
let transferred = 0;
const toUserDir = getUserAudiobookDir(toUserId);
await fs.mkdir(toUserDir, { recursive: true });
for (const bookId of bookIds) {
try {
// Move the audiobook folder
const moved = await moveAudiobookToUser(bookId, fromUserId, toUserId);
if (moved) {
// Update DB - delete old record and insert new one (composite PK)
const [oldRecord] = await db.select().from(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId))
);
if (oldRecord) {
// Get chapters
const oldChapters = await db.select().from(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId))
);
// Delete old records
await db.delete(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId))
);
await db.delete(audiobooks).where(
and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId))
);
// Insert new records with new userId
await db.insert(audiobooks).values({
...oldRecord,
userId: toUserId,
});
for (const chapter of oldChapters) {
await db.insert(audiobookChapters).values({
...chapter,
userId: toUserId,
});
}
transferred++;
console.log(`Transferred audiobook ${bookId} from ${fromUserId} to ${toUserId}`);
}
}
} catch (err) {
console.error(`Error transferring audiobook ${bookId}:`, err);
}
}
return transferred;
}

View file

@ -8,6 +8,101 @@ import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ff
export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1');
export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users');
export const UNCLAIMED_USER_ID = 'unclaimed';
/**
* Get the audiobook directory for a specific user when auth is enabled.
* Returns path like: docstore/audiobooks_users/{userId}
*/
export function getUserAudiobookDir(userId: string): string {
// Sanitize userId to prevent path traversal
const safeUserId = userId.replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeUserId || safeUserId === '.' || safeUserId === '..' || safeUserId.includes('..')) {
throw new Error('Invalid userId for audiobook directory');
}
return path.join(AUDIOBOOKS_USERS_DIR, safeUserId);
}
/**
* Get the unclaimed audiobooks directory for pre-auth content.
* Returns path like: docstore/audiobooks_users/unclaimed
*/
export function getUnclaimedAudiobookDir(): string {
return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID);
}
/**
* Get the full path to a specific audiobook directory.
* - When auth is disabled: docstore/audiobooks_v1/{bookId}-audiobook
* - When auth is enabled: docstore/audiobooks_users/{userId}/{bookId}-audiobook
*/
export function getAudiobookPath(bookId: string, userId: string | null, authEnabled: boolean): string {
if (!authEnabled || !userId) {
return path.join(AUDIOBOOKS_V1_DIR, `${bookId}-audiobook`);
}
return path.join(getUserAudiobookDir(userId), `${bookId}-audiobook`);
}
/**
* Move an audiobook folder from one user's directory to another.
* Used for claiming unclaimed audiobooks or transferring on account linking.
* @returns true if moved successfully, false if source doesn't exist
*/
export async function moveAudiobookToUser(
bookId: string,
fromUserId: string,
toUserId: string
): Promise<boolean> {
const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`);
const targetUserDir = getUserAudiobookDir(toUserId);
const targetDir = path.join(targetUserDir, `${bookId}-audiobook`);
if (!existsSync(sourceDir)) {
return false;
}
// Ensure target user directory exists
await mkdir(targetUserDir, { recursive: true });
// If target already exists, we need to merge or skip
if (existsSync(targetDir)) {
// Target exists - merge contents (move files that don't exist in target)
const result = await mergeDirectoryContents(sourceDir, targetDir);
// Try to remove source if empty
try {
const remaining = await readdir(sourceDir);
if (remaining.length === 0) {
await rm(sourceDir, { recursive: true, force: true });
}
} catch { /* ignore */ }
return result.moved > 0 || result.skipped > 0;
}
// Simple rename/move
await rename(sourceDir, targetDir);
return true;
}
/**
* List all audiobook IDs in a user's directory.
*/
export async function listUserAudiobookIds(userId: string): Promise<string[]> {
const userDir = getUserAudiobookDir(userId);
if (!existsSync(userDir)) return [];
const entries = await readdir(userDir, { withFileTypes: true });
const bookIds: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
bookIds.push(entry.name.replace('-audiobook', ''));
}
return bookIds;
}
const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations');
const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json');
@ -341,6 +436,8 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro
export async function ensureAudiobooksV1Ready(): Promise<boolean> {
await mkdir(DOCSTORE_DIR, { recursive: true });
await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true });
// Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios
await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true });
const state = await loadMigrationState();
const legacyDirsPresent = await hasLegacyAudiobookDirs();

View file

@ -17,6 +17,10 @@ export const RATE_LIMITS = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeDb = () => db as any;
type UserTtsCharsInsert = typeof userTtsChars.$inferInsert;
type UserTtsCharsDateValue = UserTtsCharsInsert['date'];
type UserTtsCharsUpdatedAtValue = UserTtsCharsInsert['updatedAt'];
export interface RateLimitResult {
allowed: boolean;
@ -99,6 +103,14 @@ function getRowsAffected(result: unknown): number {
export class RateLimiter {
constructor() { }
private isPostgres(): boolean {
return Boolean(process.env.POSTGRES_URL);
}
private getUpdatedAtValue(): Date | number {
return this.isPostgres() ? new Date() : Date.now();
}
/**
* Check if a user can use TTS and increment their char count if allowed
*/
@ -114,6 +126,7 @@ export class RateLimiter {
}
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -134,6 +147,13 @@ export class RateLimiter {
try {
if (!db) throw new Error("DB not initialized");
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
// Use a DB transaction to avoid partial increments across buckets and to avoid
// non-transactional "rollback" logic that can corrupt counts under concurrency.
// Note: We intentionally allow a request to push a bucket over its limit; we only
// block when the bucket was already exhausted before this request starts updating.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await safeDb().transaction(async (tx: any) => {
// Ensure records exist for each bucket
@ -141,31 +161,30 @@ export class RateLimiter {
await tx.insert(userTtsChars)
.values({
userId: bucket.key,
date: today as string,
date: dateValue,
charCount: 0,
})
.onConflictDoUpdate({
target: [userTtsChars.userId, userTtsChars.date],
set: { updatedAt: new Date() },
set: { updatedAt },
});
}
// Attempt to increment each bucket
// Attempt to increment each bucket. The `lt(..., limit)` guard blocks requests
// that start after the bucket is already exhausted, while still allowing a
// request to push the count over the limit.
for (const bucket of buckets) {
const updateResult = await tx.update(userTtsChars)
.set({
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
updatedAt: new Date()
updatedAt,
})
.where(and(
eq(userTtsChars.userId, bucket.key),
eq(userTtsChars.date, today as string),
eq(userTtsChars.date, dateValue),
lt(userTtsChars.charCount, bucket.limit)
));
// If any bucket is already at/over its limit, reject the request (and roll back all increments).
// Note: we intentionally allow a request to push a bucket over its limit; we only block when the
// bucket was already exhausted before this request started.
if (getRowsAffected(updateResult) <= 0) {
throw new RateLimitExceeded();
}
@ -176,7 +195,7 @@ export class RateLimiter {
for (const bucket of buckets) {
const result = await tx.select({ currentCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)));
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, dateValue)));
const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0;
bucketResults.push({ currentCount, limit: bucket.limit });
@ -272,38 +291,36 @@ export class RateLimiter {
if (!db) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await safeDb().transaction(async (tx: any) => {
const today = new Date().toISOString().split('T')[0];
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
// Get anonymous user's current count
const anonymousResult = await tx.select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue)));
if (anonymousResult.length > 0) {
const anonymousCount = Number(anonymousResult[0].charCount);
if (anonymousResult.length === 0) return;
const existingAuth = await tx.select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today)));
const anonymousCount = Number(anonymousResult[0].charCount);
if (existingAuth.length === 0) {
await tx.insert(userTtsChars).values({ userId: authenticatedUserId, date: today, charCount: anonymousCount });
} else {
const existingCount = Number(existingAuth[0].charCount);
if (anonymousCount > existingCount) {
await tx.update(userTtsChars)
.set({ charCount: anonymousCount, updatedAt: new Date() })
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today)));
}
}
const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
// Remove anonymous user's record
await tx.delete(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
if (existingAuth.length === 0) {
await safeDb().insert(userTtsChars)
.values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount });
} else {
const existingCount = Number(existingAuth[0].charCount);
if (anonymousCount > existingCount) {
await safeDb().update(userTtsChars)
.set({ charCount: anonymousCount, updatedAt })
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
}
});
}
await safeDb().delete(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue)));
}
/**
@ -313,11 +330,11 @@ export class RateLimiter {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue;
if (!db) return;
// Assuming string comparison works for YYYY-MM-DD
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateStr as any));
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue));
}
private getResetTime(): Date {

View file

@ -46,11 +46,12 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) {
page.waitForEvent('download', { timeout: timeoutMs }),
fullDownloadButton.click(),
]);
const downloadedPath = await download.path();
expect(downloadedPath).toBeTruthy();
const stats = fs.statSync(downloadedPath!);
const tempFilePath = `./tmp_download_${Date.now()}.mp3`;
await download.saveAs(tempFilePath);
expect(fs.existsSync(tempFilePath)).toBeTruthy();
const stats = fs.statSync(tempFilePath);
expect(stats.size).toBeGreaterThan(0);
return downloadedPath!;
return tempFilePath;
}
async function getAudioDurationSeconds(filePath: string) {
@ -73,6 +74,40 @@ async function expectChaptersBackendState(page: Page, bookId: string) {
return json;
}
/**
* Poll the backend until the chapter count stops changing for `stableMs` milliseconds.
* This helps avoid race conditions where in-flight TTS requests complete after cancellation.
*/
async function waitForStableChapterCount(
page: Page,
bookId: string,
{ stableMs = 2000, timeoutMs = 30000 } = {}
): Promise<{ count: number; json: ReturnType<typeof expectChaptersBackendState> extends Promise<infer T> ? T : never }> {
const startTime = Date.now();
let lastCount = -1;
let lastStableTime = Date.now();
let lastJson: Awaited<ReturnType<typeof expectChaptersBackendState>> | null = null;
while (Date.now() - startTime < timeoutMs) {
const json = await expectChaptersBackendState(page, bookId);
const currentCount = json.chapters?.length ?? 0;
lastJson = json;
if (currentCount !== lastCount) {
lastCount = currentCount;
lastStableTime = Date.now();
} else if (Date.now() - lastStableTime >= stableMs) {
// Count has been stable for stableMs
return { count: currentCount, json };
}
await page.waitForTimeout(200);
}
// Timeout reached, return whatever we have
return { count: lastCount, json: lastJson! };
}
async function resetAudiobookById(page: Page, bookId: string) {
const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`);
expect(res.ok() || res.status() === 404).toBeTruthy();
@ -96,264 +131,336 @@ async function resetAudiobookIfPresent(page: Page) {
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
}
test.describe('Audiobook export', () => {
test.describe.configure({ mode: 'serial', timeout: 120_000 });
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
// Ensure TTS is mocked and app is ready
await setupTest(page);
test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => {
// Ensure TTS is mocked and app is ready
await setupTest(page);
// Upload and open the sample PDF in the viewer
await uploadAndDisplay(page, 'sample.pdf');
// Upload and open the sample PDF in the viewer
await uploadAndDisplay(page, 'sample.pdf');
// Capture the generated document/book id from the /pdf/[id] URL
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Capture the generated document/book id from the /pdf/[id] URL
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Open the audiobook export modal from the header button
await openExportModal(page);
// Open the audiobook export modal from the header button
await openExportModal(page);
// While there are no chapters yet, we can still switch the container format.
// Choose MP3 so we can validate MP3 duration end-to-end.
await setContainerFormatToMP3(page);
// While there are no chapters yet, we can still switch the container format.
// Choose MP3 so we can validate MP3 duration end-to-end.
await setContainerFormatToMP3(page);
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
await startGeneration(page);
// Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page
await startGeneration(page);
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
// Wait for chapters list to appear and populate at least two items (Pages 1 and 2)
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
// the server-side detected format, so match more loosely on the accessible name.
const downloadedPath = await downloadFullAudiobook(page);
// Trigger full download from the FRONTEND button and capture via Playwright's download API.
// The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on
// the server-side detected format, so match more loosely on the accessible name.
const downloadedPath = await downloadFullAudiobook(page);
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
// two chapters we should be close to ~20 seconds of audio.
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// Duration must be within a reasonable window around 20 seconds to allow
// for encoding variations and container overhead.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
// Use ffprobe (same toolchain as the server) to validate the combined audio duration.
// The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least
// two chapters we should be close to ~20 seconds of audio.
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// Duration must be within a reasonable window around 20 seconds to allow
// for encoding variations and container overhead.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
// Also check the chapter metadata API for consistency
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
// Also check the chapter metadata API for consistency
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBeGreaterThanOrEqual(2);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page);
});
test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => {
await setupTest(page);
// Upload and open the sample EPUB in the viewer
await uploadAndDisplay(page, 'sample.epub');
// URL should now be /epub/[id]
const bookId = await getBookIdFromUrl(page, 'epub');
await resetAudiobookById(page, bookId);
// Open the audiobook export modal from the header button
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Progress card should appear with a Cancel button while chapters are being generated
const cancelButton = page.getByRole('button', { name: 'Cancel' });
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
// moment we decide to cancel, and assert that no additional chapters are added afterward.
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
const chapterCountBeforeCancel = await chapterActionsButtons.count();
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
// Now cancel the in-flight generation
await cancelButton.click();
// After cancellation, the inline progress card's Cancel button should be gone
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
// After cancellation, determine the canonical chapter count from the backend and
// assert that the UI eventually reflects this count. Some in-flight chapters may
// complete right as we cancel, so we treat the backend state as source of truth.
const jsonAfterCancel = await expectChaptersBackendState(page, bookId);
expect(jsonAfterCancel.exists).toBe(true);
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
const chapterCountAfterCancel = jsonAfterCancel.chapters.length;
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
// Wait for the UI to reflect the final backend chapter count to avoid race
// conditions between the modal's soft refresh and our assertions.
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
// The Full Download button should still be available for the partially generated audiobook
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
expect(durationSeconds).toBeGreaterThan(25);
expect(durationSeconds).toBeLessThan(300);
// Backend should still reflect the same number of chapters as when we first
// observed the stabilized post-cancellation state, and should not contain
// additional "impartial" chapters produced after cancellation.
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(chapterCountAfterCancel);
await resetAudiobookIfPresent(page);
});
test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// Wait for at least one chapter row to appear (one "Chapter actions" button)
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
// Download via frontend button
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
expect(durationSeconds).toBeGreaterThan(9);
expect(durationSeconds).toBeLessThan(300);
await resetAudiobookIfPresent(page);
});
test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// Wait for Reset button to become visible, indicating resumable/generated state
const resetButton = page.getByRole('button', { name: 'Reset' });
await expect(resetButton).toBeVisible({ timeout: 120_000 });
await resetButton.click();
// Confirm in the Reset Audiobook dialog
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
await confirmReset.click();
// After reset, generation should be startable again
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
// Backend should report no existing chapters for this bookId
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.exists).toBe(false);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(0);
});
test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
// Extract bookId from /pdf/[id] URL (for backend verification later)
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Open Export Audiobook modal
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Wait for chapters to appear
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
// Ensure we have at least two chapters for this PDF
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
const chapterCountBefore = await chapterActionsButtons.count();
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
// Open the actions menu for the first chapter and trigger Regenerate
const firstChapterActions = chapterActionsButtons.first();
await firstChapterActions.click();
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
await regenerateMenuItem.click();
// During regeneration, the row may show a "Regenerating" label; wait for any such
// indicator to disappear, signaling completion.
const regeneratingLabel = page.getByText(/Regenerating/);
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
// After regeneration completes in the UI, verify backend chapter state is fully updated
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
expect(backendStateAfterRegenerate.exists).toBe(true);
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
for (const ch of backendStateAfterRegenerate.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
// Chapter count should remain exactly the same after regeneration (no duplicates)
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
// Full Download should still work and produce a valid combined audiobook
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
// Backend should still report the same number of chapters and valid durations
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(chapterCountBefore);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page);
});
await resetAudiobookIfPresent(page);
});
test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }) => {
await setupTest(page);
// Upload and open the sample EPUB in the viewer
await uploadAndDisplay(page, 'sample.epub');
// URL should now be /epub/[id]
const bookId = await getBookIdFromUrl(page, 'epub');
await resetAudiobookById(page, bookId);
// Open the audiobook export modal from the header button
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Progress card should appear with a Cancel button while chapters are being generated
const cancelButton = page.getByRole('button', { name: 'Cancel' });
await expect(cancelButton).toBeVisible({ timeout: 60_000 });
await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 });
// Wait until at least 3 chapters are listed in the UI; record the exact count at the
// moment we decide to cancel, and assert that no additional chapters are added afterward.
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 });
const chapterCountBeforeCancel = await chapterActionsButtons.count();
expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3);
// Now cancel the in-flight generation
await cancelButton.click();
// After cancellation, the inline progress card's Cancel button should be gone
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
// After cancellation, wait for the chapter count to stabilize. In-flight TTS
// requests may still complete after we click cancel, so we poll until the
// count stops changing for a brief period.
const { count: chapterCountAfterCancel, json: jsonAfterCancel } = await waitForStableChapterCount(
page,
bookId,
{ stableMs: 2000, timeoutMs: 30000 }
);
expect(jsonAfterCancel.exists).toBe(true);
expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true);
expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel);
// Wait for the UI to reflect the final backend chapter count to avoid race
// conditions between the modal's soft refresh and our assertions.
await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 });
// The Full Download button should still be available for the partially generated audiobook
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
expect(durationSeconds).toBeGreaterThan(25);
expect(durationSeconds).toBeLessThan(300);
// Backend should still reflect the same number of chapters as when we first
// observed the stabilized post-cancellation state.
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(chapterCountAfterCancel);
await resetAudiobookIfPresent(page);
});
test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// Wait for at least one chapter row to appear (one "Chapter actions" button)
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 });
// Download via frontend button
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter.
expect(durationSeconds).toBeGreaterThan(9);
expect(durationSeconds).toBeLessThan(300);
await resetAudiobookIfPresent(page);
});
test('resets all MP3 audiobook PDF pages', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
// Wait for Reset button to become visible, indicating resumable/generated state
const resetButton = page.getByRole('button', { name: 'Reset' });
await expect(resetButton).toBeVisible({ timeout: 120_000 });
await resetButton.click();
// Confirm in the Reset Audiobook dialog
await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 });
const confirmReset = page.getByRole('button', { name: 'Reset' }).last();
await confirmReset.click();
// After reset, generation should be startable again
await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 });
// Backend should report no existing chapters for this bookId
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.exists).toBe(false);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(0);
});
test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
// Extract bookId from /pdf/[id] URL (for backend verification later)
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
// Open Export Audiobook modal
await openExportModal(page);
// Set container format to MP3
await setContainerFormatToMP3(page);
// Start generation
await startGeneration(page);
// Wait for chapters to appear
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
// Ensure we have at least two chapters for this PDF
await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 });
const chapterCountBefore = await chapterActionsButtons.count();
expect(chapterCountBefore).toBeGreaterThanOrEqual(2);
// Open the actions menu for the first chapter and trigger Regenerate
const firstChapterActions = chapterActionsButtons.first();
await firstChapterActions.click();
// In the headlessui Menu, each option is a menuitem. Use that role instead of button.
const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i });
await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 });
await regenerateMenuItem.click();
// During regeneration, the row may show a "Regenerating" label; wait for any such
// indicator to disappear, signaling completion.
const regeneratingLabel = page.getByText(/Regenerating/);
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
// After regeneration completes in the UI, verify backend chapter state is fully updated
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
expect(backendStateAfterRegenerate.exists).toBe(true);
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
for (const ch of backendStateAfterRegenerate.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
// Chapter count should remain exactly the same after regeneration (no duplicates)
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
// Full Download should still work and produce a valid combined audiobook
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
// With two mocked 10s chapters we expect roughly 20s; allow a small window.
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
// Backend should still report the same number of chapters and valid durations
const json = await expectChaptersBackendState(page, bookId);
expect(json.exists).toBe(true);
expect(Array.isArray(json.chapters)).toBe(true);
expect(json.chapters.length).toBe(chapterCountBefore);
for (const ch of json.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page);
});
test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }) => {
await setupTest(page);
await uploadAndDisplay(page, 'sample.pdf');
const bookId = await getBookIdFromUrl(page, 'pdf');
await resetAudiobookById(page, bookId);
await openExportModal(page);
await setContainerFormatToMP3(page);
await startGeneration(page);
await waitForChaptersHeading(page);
const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' });
await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 });
// Delete the first chapter via the backend API so the audiobook has a missing index (0).
// This is more reliable than clicking through the chapter actions menu in headless runs.
const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`);
expect(deleteRes.ok()).toBeTruthy();
// Wait for backend to reflect only one remaining chapter (index 1).
await expect
.poll(async () => {
const json = await expectChaptersBackendState(page, bookId);
return json.chapters?.length ?? 0;
}, { timeout: 30_000 })
.toBe(1);
const jsonAfterDelete = await expectChaptersBackendState(page, bookId);
expect(jsonAfterDelete.exists).toBe(true);
expect(Array.isArray(jsonAfterDelete.chapters)).toBe(true);
expect(jsonAfterDelete.chapters.length).toBe(1);
expect(jsonAfterDelete.chapters[0]?.index).toBe(1);
// Close and reopen the modal to ensure "resume" loads the missing placeholder from the backend.
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toHaveCount(0);
await openExportModal(page);
await waitForChaptersHeading(page);
await expect(page.getByText(/Missing •/)).toHaveCount(1, { timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 15_000 });
// Resume should regenerate the missing chapter and allow a full download to succeed.
await page.getByRole('button', { name: 'Resume' }).click();
// Wait for backend to have both chapters again.
await expect
.poll(async () => {
const json = await expectChaptersBackendState(page, bookId);
return json.chapters?.length ?? 0;
}, { timeout: 120_000 })
.toBe(2);
// UI should also stop showing a missing placeholder after resume completes.
await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 });
const downloadedPath = await downloadFullAudiobook(page);
const durationSeconds = await getAudioDurationSeconds(downloadedPath);
expect(durationSeconds).toBeGreaterThan(18);
expect(durationSeconds).toBeLessThan(22);
const jsonAfterResume = await expectChaptersBackendState(page, bookId);
expect(jsonAfterResume.exists).toBe(true);
expect(Array.isArray(jsonAfterResume.chapters)).toBe(true);
expect(jsonAfterResume.chapters.length).toBe(2);
expect(jsonAfterResume.chapters.map((c: { index: number }) => c.index).sort()).toEqual([0, 1]);
for (const ch of jsonAfterResume.chapters) {
expect(ch.duration).toBeGreaterThan(0);
}
await resetAudiobookIfPresent(page);
});

View file

@ -6,50 +6,6 @@ const DIR = './tests/files/';
const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3');
let ttsMockBuffer: Buffer | null = null;
type RateLimitStatusResponse = {
authEnabled: boolean;
userType?: 'anonymous' | 'authenticated' | 'unauthenticated';
};
async function getRateLimitStatus(page: Page): Promise<RateLimitStatusResponse | null> {
try {
const res = await page.request.get('/api/rate-limit/status');
if (!res.ok()) return null;
return (await res.json()) as RateLimitStatusResponse;
} catch {
return null;
}
}
async function ensureAnonymousSession(page: Page): Promise<void> {
const initial = await getRateLimitStatus(page);
if (!initial) return;
if (!initial.authEnabled) return;
if (initial.userType && initial.userType !== 'unauthenticated') return;
// Create a session cookie for this test context.
// This avoids races where the app makes authenticated API calls before AuthLoader finishes.
try {
await page.request.post('/api/auth/sign-in/anonymous', { data: {} });
} catch {
// ignore
}
// Wait until the server sees us as anonymous/authenticated (i.e. cookie persisted).
const deadline = Date.now() + 15_000;
// eslint-disable-next-line no-constant-condition
while (true) {
const next = await getRateLimitStatus(page);
if (next && (!next.authEnabled || (next.userType && next.userType !== 'unauthenticated'))) {
return;
}
if (Date.now() > deadline) {
throw new Error('Timed out waiting for anonymous auth session in tests');
}
await page.waitForTimeout(200);
}
}
async function ensureTtsRouteMock(page: Page) {
if (!ttsMockBuffer) {
ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH);