diff --git a/.env.example b/.env.example index dba84d8..4e1a1ad 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -# Determins the environment mode, `production` limits many features -NEXT_PUBLIC_NODE_ENV=development # Not availble in Docker containers (due to being build-time only) +# (Optional) Client feature flags (does not work in Docker containers due to being build-time only) +NEXT_PUBLIC_NODE_ENV=development +NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT= +NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT= # Local / OpenAI TTS API Configuration (default) # Suggest using https://github.com/remsky/Kokoro-FastAPI @@ -23,9 +25,6 @@ API_KEY=api_key_optional # TTS_IP_DAILY_LIMIT_ANONYMOUS=100000 # TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000 -# Path to your local whisper.cpp CLI binary for STT timestamp generation -WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli - # Auth configuration (recommended for contributors and public instances) # (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -37,28 +36,40 @@ GITHUB_CLIENT_SECRET= # (Optional) Disable Better Auth built-in rate limiting (useful for testing) DISABLE_AUTH_RATE_LIMIT= -# Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. +# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. POSTGRES_URL= -# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) -RUN_DB_MIGRATIONS= # Embedded SeaweedFS weed mini config +# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`) USE_EMBEDDED_WEED_MINI= WEED_MINI_DIR= WEED_MINI_WAIT_SEC= # S3 storage config (use with embedded weed mini or external S3-compatible storage) -# For embedded weed mini, set explicit keys if you want stable credentials across restarts. +# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts. S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_BUCKET= S3_REGION= -# If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. +# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. S3_ENDPOINT= S3_FORCE_PATH_STYLE= S3_PREFIX= -# Server library import roots (optional, uses /docstore/library by default) +# Migrations configuration +# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) +RUN_DRIZZLE_MIGRATIONS= +# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`) +RUN_FS_MIGRATIONS= + +# (Optional) Server library import roots (uses /docstore/library by default) IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= + +# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation +WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli + +# (Optional) Override ffmpeg/ffprobe binary paths used for audiobook processing/probing +FFMPEG_BIN= +FFPROBE_BIN= diff --git a/Dockerfile b/Dockerfile index 6a37bb2..d985b1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,9 +44,9 @@ RUN pnpm build FROM node:lts-alpine AS runner # Add runtime OS dependencies: -# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper) # - libreoffice-writer: required for DOCX → PDF conversion -RUN apk add --no-cache ca-certificates ffmpeg libreoffice-writer +# ffmpeg/ffprobe are provided by ffmpeg-static/ffprobe-static from node_modules. +RUN apk add --no-cache ca-certificates libreoffice-writer # Install pnpm globally for running the app RUN npm install -g pnpm diff --git a/README.md b/README.md index 2bf393e..dbb6df4 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ OpenReader WebUI is an open source, self-host-friendly text-to-speech document r | Goal | Link | | --- | --- | | Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/getting-started/docker-quick-start) | +| Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/getting-started/vercel-deployment) | | Develop locally | [Local Development](https://docs.openreader.richardr.dev/getting-started/local-development) | | Configure auth | [Auth](https://docs.openreader.richardr.dev/guides/configuration) | -| Configure SQL database | [SQL Database](https://docs.openreader.richardr.dev/operations/database-and-migrations) | +| Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/operations/database-and-migrations) | | Configure object storage | [Object / Blob Storage](https://docs.openreader.richardr.dev/guides/storage-and-blob-behavior) | | Configure TTS providers | [TTS Providers](https://docs.openreader.richardr.dev/guides/tts-providers) | | Run Kokoro locally | [Kokoro-FastAPI](https://docs.openreader.richardr.dev/integrations/kokoro-fastapi) | diff --git a/docs-site/docs/guides/configuration.md b/docs-site/docs/configure/configuration.md similarity index 82% rename from docs-site/docs/guides/configuration.md rename to docs-site/docs/configure/configuration.md index 0c9e305..9e84114 100644 --- a/docs-site/docs/guides/configuration.md +++ b/docs-site/docs/configure/configuration.md @@ -12,8 +12,8 @@ This page covers application-level configuration for provider access and authent ## Related docs -- For the complete variable reference: [Environment Variables](./environment-variables) +- For the complete variable reference: [Environment Variables](../reference/environment-variables) - For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting) - For provider-specific guidance: [TTS Providers](./tts-providers) - For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./storage-and-blob-behavior) -- For database mode and migration commands: [SQL Database](../operations/database-and-migrations) +- For database mode and migration commands: [Database and Migrations](./database-and-migrations) diff --git a/docs-site/docs/operations/database-and-migrations.md b/docs-site/docs/configure/database-and-migrations.md similarity index 53% rename from docs-site/docs/operations/database-and-migrations.md rename to docs-site/docs/configure/database-and-migrations.md index e38eba5..50bc434 100644 --- a/docs-site/docs/operations/database-and-migrations.md +++ b/docs-site/docs/configure/database-and-migrations.md @@ -1,5 +1,5 @@ --- -title: SQL Database +title: Database and Migrations --- This page covers database mode selection and migration behavior for OpenReader WebUI. @@ -11,17 +11,23 @@ This page covers database mode selection and migration behavior for OpenReader W ## Startup migration behavior -By default, the shared entrypoint runs DB migrations automatically before app startup in: +By default, the shared entrypoint runs migrations automatically before app startup in: - Docker container startup - `pnpm dev` - `pnpm start` +Startup migration phases: + +- DB schema migrations (`pnpm migrate`) +- Storage/data migration (`pnpm migrate-fs`) for legacy filesystem content into S3 + DB rows + To skip automatic startup migrations: -- Set `RUN_DB_MIGRATIONS=false` +- Set `RUN_DRIZZLE_MIGRATIONS=false` +- Set `RUN_FS_MIGRATIONS=false` -Database variables are documented in [Environment Variables](../guides/environment-variables). +Database variables are documented in [Environment Variables](../reference/environment-variables). ## Common project commands @@ -31,6 +37,12 @@ In most cases, you do not need manual migration commands because startup runs mi # Run pending migrations (uses Postgres config when POSTGRES_URL is set, otherwise SQLite) pnpm migrate +# Run storage migration (filesystem -> S3 + DB) +pnpm migrate-fs + +# Dry-run storage migration without uploading/deleting +pnpm migrate-fs:dry-run + # Generate new migration files for both SQLite and Postgres outputs pnpm generate ``` @@ -39,14 +51,14 @@ pnpm generate ```bash # Migrate SQLite -npx drizzle-kit migrate --config drizzle.config.sqlite.ts +pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts # Migrate Postgres -npx drizzle-kit migrate --config drizzle.config.pg.ts +pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts # Generate SQLite migrations -npx drizzle-kit generate --config drizzle.config.sqlite.ts +pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts # Generate Postgres migrations -npx drizzle-kit generate --config drizzle.config.pg.ts +pnpm exec drizzle-kit generate --config drizzle.config.pg.ts ``` diff --git a/docs-site/docs/guides/storage-and-blob-behavior.md b/docs-site/docs/configure/storage-and-blob-behavior.md similarity index 75% rename from docs-site/docs/guides/storage-and-blob-behavior.md rename to docs-site/docs/configure/storage-and-blob-behavior.md index fba6c6e..f437ce9 100644 --- a/docs-site/docs/guides/storage-and-blob-behavior.md +++ b/docs-site/docs/configure/storage-and-blob-behavior.md @@ -9,7 +9,7 @@ This page documents storage backends, blob upload routing, and Docker mount beha - Default: embedded SQLite metadata + embedded SeaweedFS (`weed mini`) blobs - External option: Postgres + external S3-compatible object storage -Storage variables are documented in [Environment Variables](./environment-variables) under the storage sections. +Storage variables are documented in [Environment Variables](../reference/environment-variables) under the storage sections. ## Ports @@ -26,7 +26,7 @@ Storage variables are documented in [Environment Variables](./environment-variab | Mount | Type | Recommended | Purpose | Example | | --- | --- | --- | --- | --- | -| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, audiobook artifacts, migration/runtime files | `-v openreader_docstore:/app/docstore` | +| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state | `-v openreader_docstore:/app/docstore` | | `/app/docstore/library` | Bind mount | Optional + `:ro` | Read-only source for server library import (files are copied/imported into client storage) | `-v /path/to/your/library:/app/docstore/library:ro` | To import from mounted library: **Settings -> Documents -> Server Library Import**. @@ -42,3 +42,8 @@ If `8333` is not published externally: - Document uploads still work through upload fallback proxy - Reads/snippets continue through app API routes - Direct presigned browser upload/download to embedded endpoint is unavailable + +## Audiobook storage note + +- In current versions, audiobook assets live in object storage (`audiobooks_v1` keyspace), not as durable files under `/app/docstore`. +- Local filesystem usage for audiobook routes is temporary processing only. diff --git a/docs-site/docs/guides/tts-providers.md b/docs-site/docs/configure/tts-providers.md similarity index 94% rename from docs-site/docs/guides/tts-providers.md rename to docs-site/docs/configure/tts-providers.md index 7cf42bf..8351ff2 100644 --- a/docs-site/docs/guides/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -42,7 +42,7 @@ If your provider exposes this interface, it can be used as an OpenAI-compatible 3. Set `API_KEY` if required by your provider. 4. Choose model and voice. -For environment variables, see [Environment Variables](./environment-variables). +For environment variables, see [Environment Variables](../reference/environment-variables). For TTS quota behavior, see [TTS Rate Limiting](./tts-rate-limiting). For auth behavior, see [Auth](./configuration). For provider-specific integration guides, see [Kokoro-FastAPI](../integrations/kokoro-fastapi), [Orpheus-FastAPI](../integrations/orpheus-fastapi), [Deepinfra](../integrations/deepinfra), [OpenAI](../integrations/openai), and [Custom OpenAI](../integrations/custom-openai). diff --git a/docs-site/docs/guides/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md similarity index 94% rename from docs-site/docs/guides/tts-rate-limiting.md rename to docs-site/docs/configure/tts-rate-limiting.md index 851c4c8..8bdf336 100644 --- a/docs-site/docs/guides/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -46,6 +46,6 @@ IP backstop daily limits: ## Related docs -- Full variable list: [Environment Variables](./environment-variables) +- Full variable list: [Environment Variables](../reference/environment-variables) - Auth configuration: [Auth](./configuration) - Provider setup: [TTS Providers](./tts-providers) diff --git a/docs-site/docs/integrations/custom-openai.md b/docs-site/docs/integrations/custom-openai.md index 2a23fe4..bacf6a7 100644 --- a/docs-site/docs/integrations/custom-openai.md +++ b/docs-site/docs/integrations/custom-openai.md @@ -25,4 +25,4 @@ Your provider should expose: - `API_BASE` is required for this provider path because OpenReader cannot infer your custom host. - If voices do not load, verify the `/v1/audio/voices` response format. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/deepinfra.md b/docs-site/docs/integrations/deepinfra.md index d080f61..f242031 100644 --- a/docs-site/docs/integrations/deepinfra.md +++ b/docs-site/docs/integrations/deepinfra.md @@ -16,4 +16,4 @@ Use Deepinfra as a hosted OpenAI-compatible TTS provider. - `Deepinfra` is a built-in provider in the dropdown, so `API_BASE` is usually not required. - Deepinfra supports multiple TTS models, including Kokoro-family options. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/openai.md b/docs-site/docs/integrations/openai.md index 72e97fd..5636935 100644 --- a/docs-site/docs/integrations/openai.md +++ b/docs-site/docs/integrations/openai.md @@ -15,4 +15,4 @@ Use OpenAI directly as an OpenAI-compatible TTS provider. - `OpenAI` is a built-in provider in the dropdown, so `API_BASE` is usually not required. - OpenReader routes TTS calls through its server API. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/orpheus-fastapi.md b/docs-site/docs/integrations/orpheus-fastapi.md index a3f0c24..76dc760 100644 --- a/docs-site/docs/integrations/orpheus-fastapi.md +++ b/docs-site/docs/integrations/orpheus-fastapi.md @@ -20,4 +20,4 @@ Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader. - `API_BASE` is needed here because Orpheus is configured through the custom provider path. - OpenReader expects OpenAI-compatible audio endpoints. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index 34a3c36..8a7b2e2 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -27,13 +27,13 @@ It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenA ## Start Here -- [Docker Quick Start](./getting-started/docker-quick-start) -- [Local Development](./getting-started/local-development) -- [Environment Variables](./guides/environment-variables) -- [Auth](./guides/configuration) -- [SQL Database](./operations/database-and-migrations) -- [Object / Blob Storage](./guides/storage-and-blob-behavior) -- [TTS Providers](./guides/tts-providers) +- [Docker Quick Start](./start-here/docker-quick-start) +- [Local Development](./start-here/local-development) +- [Environment Variables](./reference/environment-variables) +- [Auth](./configure/configuration) +- [Database and Migrations](./configure/database-and-migrations) +- [Object / Blob Storage](./configure/storage-and-blob-behavior) +- [TTS Providers](./configure/tts-providers) ## Source Repository diff --git a/docs-site/docs/community/acknowledgements.md b/docs-site/docs/project/acknowledgements.md similarity index 100% rename from docs-site/docs/community/acknowledgements.md rename to docs-site/docs/project/acknowledgements.md diff --git a/docs-site/docs/community/license.md b/docs-site/docs/project/license.md similarity index 100% rename from docs-site/docs/community/license.md rename to docs-site/docs/project/license.md diff --git a/docs-site/docs/community/support.md b/docs-site/docs/project/support.md similarity index 100% rename from docs-site/docs/community/support.md rename to docs-site/docs/project/support.md diff --git a/docs-site/docs/guides/environment-variables.md b/docs-site/docs/reference/environment-variables.md similarity index 79% rename from docs-site/docs/guides/environment-variables.md rename to docs-site/docs/reference/environment-variables.md index f4ce7c6..0292cbb 100644 --- a/docs-site/docs/guides/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -10,6 +10,8 @@ This is the single reference page for OpenReader WebUI environment variables. | Variable | Area | Default | When to set | | --- | --- | --- | --- | | `NEXT_PUBLIC_NODE_ENV` | Runtime mode | `development` | Set `production` for production builds | +| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Disable audiobook export UI in any environment | +| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `true` in dev, `false` in production | Force-enable word highlight UI in production | | `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL | | `API_KEY` | TTS provider | `none` fallback in TTS route | Set when provider requires auth | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | @@ -23,7 +25,6 @@ This is the single reference page for OpenReader WebUI environment variables. | `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit | | `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit | | `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit | -| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | | `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | @@ -31,7 +32,6 @@ This is the single reference page for OpenReader WebUI environment variables. | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | | `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | | `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres | -| `RUN_DB_MIGRATIONS` | Database | `true` | Set `false` to skip startup migrations | | `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only | | `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory | | `WEED_MINI_WAIT_SEC` | Storage | `20` | Tune SeaweedFS startup wait timeout | @@ -42,8 +42,13 @@ This is the single reference page for OpenReader WebUI environment variables. | `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) | | `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement | | `S3_PREFIX` | Storage | `openreader` | Customize object key prefix | +| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations | +| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | +| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | +| `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path | ## Detailed Reference @@ -54,6 +59,21 @@ Controls development vs production behavior in client/server code paths. - Typical values: `development`, `production` - In production builds, set `production` +### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT + +Controls whether audiobook export UI/actions are shown in the client. + +- Default behavior: enabled unless explicitly set to `false` +- Applies in both development and production + +### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT + +Controls word-by-word highlighting UI in production builds. + +- Development default: enabled +- Production default: disabled unless set to `true` +- Requires working timestamp generation (for example `WHISPER_CPP_BIN`) + ### API_BASE Base URL for OpenAI-compatible TTS API requests. @@ -110,7 +130,7 @@ Controls TTS character rate limiting in the TTS API. - Default: `false` (TTS char limits disabled) - Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*` -- For behavior details and examples, see [TTS Rate Limiting](./tts-rate-limiting) +- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting) ### TTS_DAILY_LIMIT_ANONYMOUS @@ -136,13 +156,6 @@ Authenticated IP backstop daily character limit. - Default: `1000000` -### WHISPER_CPP_BIN - -Absolute path to compiled `whisper.cpp` binary for word-level timestamps. - -- Example: `/whisper.cpp/build/bin/whisper-cli` -- Required only for optional word-by-word highlighting - ### BASE_URL External base URL for this OpenReader instance. @@ -191,13 +204,6 @@ Switches metadata/auth storage from SQLite to Postgres. - Unset: SQLite at `docstore/sqlite3.db` - Set: Postgres mode -### RUN_DB_MIGRATIONS - -Controls startup migration execution in shared entrypoint. - -- Default: `true` -- Set `false` to skip automatic startup migrations - ### USE_EMBEDDED_WEED_MINI Controls embedded SeaweedFS startup. @@ -265,6 +271,21 @@ Prefix prepended to stored object keys. - Default: `openreader` +### RUN_DRIZZLE_MIGRATIONS + +Controls startup migration execution in shared entrypoint. + +- Default: `true` +- Set `false` to skip automatic startup Drizzle schema migrations + +### RUN_FS_MIGRATIONS + +Controls startup filesystem-to-object-store migration execution in shared entrypoint. + +- Default: `true` +- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations +- Set `false` to skip automatic storage migration pass + ### IMPORT_LIBRARY_DIR Single directory root for server library import. @@ -278,3 +299,24 @@ Multiple library roots for server library import. - Separator: comma, colon, or semicolon - Takes precedence over `IMPORT_LIBRARY_DIR` + +### WHISPER_CPP_BIN + +Absolute path to compiled `whisper.cpp` binary for word-level timestamps. + +- Example: `/whisper.cpp/build/bin/whisper-cli` +- Required only for optional word-by-word highlighting + +### FFMPEG_BIN + +Absolute path or executable name for the ffmpeg binary used by audiobook/processing routes. + +- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static` +- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg` + +### FFPROBE_BIN + +Absolute path or executable name for the ffprobe binary used for audio probing. + +- Resolution order: `FFPROBE_BIN` -> `ffprobe-static` +- Example: `/var/task/node_modules/ffprobe-static/bin/linux/x64/ffprobe` diff --git a/docs-site/docs/getting-started/docker-quick-start.md b/docs-site/docs/start-here/docker-quick-start.md similarity index 85% rename from docs-site/docs/getting-started/docker-quick-start.md rename to docs-site/docs/start-here/docker-quick-start.md index f44f76e..62bafa4 100644 --- a/docs-site/docs/getting-started/docker-quick-start.md +++ b/docs-site/docs/start-here/docker-quick-start.md @@ -50,10 +50,10 @@ Quick notes: - To enable auth, set both `BASE_URL` and `AUTH_SECRET`. - DB migrations run automatically during container startup via the shared entrypoint. -For all environment variables, see [Environment Variables](../guides/environment-variables). -For app/auth behavior, see [Auth](../guides/configuration). -For database startup and migration behavior, see [SQL Database](../operations/database-and-migrations). -For blob behavior and mounts, see [Object / Blob Storage](../guides/storage-and-blob-behavior). +For all environment variables, see [Environment Variables](../reference/environment-variables). +For app/auth behavior, see [Auth](../configure/configuration). +For database startup and migration behavior, see [Database and Migrations](../configure/database-and-migrations). +For blob behavior and mounts, see [Object / Blob Storage](../configure/storage-and-blob-behavior). ## 2. Configure settings in the app UI diff --git a/docs-site/docs/getting-started/local-development.md b/docs-site/docs/start-here/local-development.md similarity index 82% rename from docs-site/docs/getting-started/local-development.md rename to docs-site/docs/start-here/local-development.md index ffdb578..521a9b2 100644 --- a/docs-site/docs/getting-started/local-development.md +++ b/docs-site/docs/start-here/local-development.md @@ -20,12 +20,6 @@ brew install seaweedfs Optional, depending on features: -- [FFmpeg](https://ffmpeg.org) (required for `m4b` audiobook generation) - -```bash -brew install ffmpeg -``` - - [libreoffice](https://www.libreoffice.org) (required for DOCX conversion) ```bash @@ -83,10 +77,10 @@ Optional: - Stable S3 credentials via `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` - External S3 storage by setting `USE_EMBEDDED_WEED_MINI=false` and related S3 vars -For all environment variables, see [Environment Variables](../guides/environment-variables). -For app/auth behavior, see [Auth](../guides/configuration). -For storage configuration, see [Object / Blob Storage](../guides/storage-and-blob-behavior). -For database mode and migrations, see [SQL Database](../operations/database-and-migrations). +For all environment variables, see [Environment Variables](../reference/environment-variables). +For app/auth behavior, see [Auth](../configure/configuration). +For storage configuration, see [Object / Blob Storage](../configure/storage-and-blob-behavior). +For database mode and migrations, see [Database and Migrations](../configure/database-and-migrations). 4. Run DB migrations. @@ -98,7 +92,7 @@ pnpm migrate ``` :::note -If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DB_MIGRATIONS=false`. +If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DRIZZLE_MIGRATIONS=false` and/or `RUN_FS_MIGRATIONS=false`. You can run storage migration manually with `pnpm migrate-fs`. ::: 5. Start the app. diff --git a/docs-site/docs/start-here/vercel-deployment.md b/docs-site/docs/start-here/vercel-deployment.md new file mode 100644 index 0000000..50d06fe --- /dev/null +++ b/docs-site/docs/start-here/vercel-deployment.md @@ -0,0 +1,78 @@ +--- +title: Vercel Deployment +--- + +This guide covers deploying OpenReader WebUI to Vercel with external Postgres and S3-compatible object storage. + +## What works on Vercel + +- Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. +- Audiobook routes work on Node.js serverless functions using `ffmpeg-static`/`ffprobe-static`. +- `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. + +## 1. Required environment variables + +Set these in your Vercel project: + +```bash +POSTGRES_URL=postgres://... +USE_EMBEDDED_WEED_MINI=false +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... +S3_BUCKET=... +S3_REGION=us-east-1 +S3_ENDPOINT=https://... +S3_FORCE_PATH_STYLE=true +S3_PREFIX=openreader +``` + +Optional but common: + +```bash +BASE_URL=https://your-app.vercel.app +AUTH_SECRET=... +NEXT_PUBLIC_NODE_ENV=production +NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true +NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true +``` + +For all variables and defaults, see [Environment Variables](../reference/environment-variables). + +## 2. FFmpeg/ffprobe packaging in Vercel functions + +`ffmpeg-static` and `ffprobe-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for: + +- `/api/audiobook(.*)` +- `/api/whisper` + +If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly. + +## 3. Function memory sizing + +FFmpeg workloads benefit from more memory/CPU. This repo includes: + +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "functions": { + "app/api/audiobook/route.ts": { "memory": 3009 }, + "app/api/whisper/route.ts": { "memory": 3009 } + } +} +``` + +Adjust memory per route if your files are larger or your plan differs. + +## 4. Runtime expectations and caveats + +- Audiobook APIs require S3 configuration; otherwise they return `503`. +- `better-sqlite3` remains in `serverExternalPackages` for mixed/self-host setups, but production Vercel should use `POSTGRES_URL`. +- Filesystem-to-object-store migrations run via server scripts/entrypoint (`scripts/migrate-fs-v2.mjs`), not API routes. +- Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so run `pnpm migrate-fs` in a controlled environment when migrating legacy filesystem data. + +## 5. Smoke test after deploy + +1. Upload and read a PDF/EPUB document. +2. Confirm sync/blob fetch works across refreshes/devices. +3. Generate at least one audiobook chapter and play/download it. +4. If using word highlighting, verify timestamps are produced and rendered. diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index d1f1813..8cbef16 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -5,6 +5,7 @@ import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { title: 'OpenReader WebUI Docs', tagline: 'Docs for OpenReader', + favicon: 'favicon.ico', future: { v4: true, @@ -97,7 +98,7 @@ const config: Config = { { title: 'Community', items: [ - { label: 'Support', to: '/community/support' }, + { label: 'Support', to: '/project/support' }, { label: 'GitHub Discussions', href: 'https://github.com/richardr1126/OpenReader-WebUI/discussions' }, { label: 'Issues', href: 'https://github.com/richardr1126/OpenReader-WebUI/issues' }, ], diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 0ebfa55..291cf19 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -6,13 +6,13 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Start Here', - items: ['getting-started/docker-quick-start', 'getting-started/local-development'], + items: ['start-here/docker-quick-start', 'start-here/vercel-deployment', 'start-here/local-development'], }, { type: 'category', label: 'Reference', items: [ - 'guides/environment-variables', + 'reference/environment-variables', 'reference/stack', ], }, @@ -20,15 +20,15 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Configure', items: [ - 'guides/tts-providers', + 'configure/tts-providers', { type: 'doc', - id: 'guides/configuration', + id: 'configure/configuration', label: 'Auth (Reccomended)', }, - 'guides/tts-rate-limiting', - 'operations/database-and-migrations', - 'guides/storage-and-blob-behavior', + 'configure/tts-rate-limiting', + 'configure/database-and-migrations', + 'configure/storage-and-blob-behavior', ], }, { @@ -45,7 +45,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Project', - items: ['community/support', 'community/acknowledgements', 'community/license'], + items: ['project/support', 'project/acknowledgements', 'project/license'], }, ], }; diff --git a/docs-site/static/favicon.ico b/docs-site/static/favicon.ico new file mode 100644 index 0000000..afd0f75 Binary files /dev/null and b/docs-site/static/favicon.ico differ diff --git a/next.config.ts b/next.config.ts index c6b910e..c10b3b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,18 @@ const nextConfig: NextConfig = { }, }, serverExternalPackages: ["better-sqlite3"], + outputFileTracingIncludes: { + '/api/audiobook(.*)': [ + 'node_modules/ffmpeg-static/ffmpeg', + 'node_modules/ffprobe-static/bin/linux/*/ffprobe', + 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', + 'node_modules/.pnpm/ffprobe-static@*/node_modules/ffprobe-static/bin/linux/*/ffprobe', + ], + '/api/whisper': [ + 'node_modules/ffmpeg-static/ffmpeg', + 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index d1f8bfc..52e1678 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "lint": "next lint", "test": "playwright test", "migrate": "node drizzle/scripts/migrate.mjs", + "migrate-fs": "node scripts/migrate-fs-v2.mjs", + "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "generate": "node drizzle/scripts/generate.mjs", "docs:init": "pnpm --dir docs-site install", "docs:dev": "pnpm --dir docs-site start", @@ -20,34 +22,34 @@ "docs:clear": "pnpm --dir docs-site clear" }, "dependencies": { - "@aws-sdk/client-s3": "^3.985.0", - "@aws-sdk/s3-request-presigner": "^3.985.0", + "@aws-sdk/client-s3": "^3.987.0", + "@aws-sdk/s3-request-presigner": "^3.987.0", "@headlessui/react": "^2.2.9", - "@types/howler": "^2.2.12", - "@types/uuid": "^10.0.0", "@vercel/analytics": "^1.6.1", - "better-auth": "^1.4.17", + "better-auth": "^1.4.18", "better-sqlite3": "^12.6.2", - "cmpstr": "^3.2.0", + "cmpstr": "^3.2.1", "compromise": "^14.14.5", "core-js": "^3.48.0", - "dexie": "^4.2.1", + "dexie": "^4.3.0", "dexie-react-hooks": "^4.2.0", - "dotenv": "^17.2.3", - "drizzle-kit": "^0.31.8", + "dotenv": "^17.2.4", + "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "epubjs": "^0.3.93", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", "howler": "^2.2.4", - "lru-cache": "^11.2.4", - "next": "^15.5.9", - "openai": "^6.16.0", + "lru-cache": "^11.2.6", + "next": "^15.5.12", + "openai": "^6.21.0", "pdfjs-dist": "4.8.69", - "pg": "^8.17.2", - "react": "^19.2.3", + "pg": "^8.18.0", + "react": "^19.2.4", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^19.2.3", - "react-dropzone": "^14.3.8", + "react-dom": "^19.2.4", + "react-dropzone": "^14.4.1", "react-hot-toast": "^2.6.0", "react-markdown": "^10.1.0", "react-pdf": "^9.2.1", @@ -57,22 +59,26 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@playwright/test": "^1.58.0", + "@playwright/test": "^1.58.2", "@tailwindcss/typography": "^0.5.19", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.19.30", + "@types/ffprobe-static": "^2.0.3", + "@types/node": "^20.19.33", "@types/pg": "^8.16.0", - "@types/react": "^19.2.9", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", + "@types/howler": "^2.2.12", + "@types/uuid": "^10.0.0", "eslint": "^9.39.2", - "eslint-config-next": "^15.5.9", + "eslint-config-next": "^15.5.12", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "typescript": "^5.9.3" }, "pnpm": { "onlyBuiltDependencies": [ - "better-sqlite3" + "better-sqlite3", + "ffmpeg-static" ], "overrides": { "lodash": "^4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41538b..6e69f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,14 +14,14 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.985.0 - version: 3.985.0 + specifier: ^3.987.0 + version: 3.987.0 '@aws-sdk/s3-request-presigner': - specifier: ^3.985.0 - version: 3.985.0 + specifier: ^3.987.0 + version: 3.987.0 '@headlessui/react': specifier: ^2.2.9 - version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/howler': specifier: ^2.2.12 version: 2.2.12 @@ -30,16 +30,16 @@ importers: version: 10.0.0 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) better-auth: - specifier: ^1.4.17 - version: 1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.4.18 + version: 1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-sqlite3: specifier: ^12.6.2 version: 12.6.2 cmpstr: - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.2.1 + version: 3.2.1 compromise: specifier: ^14.14.5 version: 14.14.5 @@ -47,68 +47,74 @@ importers: specifier: ^3.48.0 version: 3.48.0 dexie: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.0 + version: 4.3.0 dexie-react-hooks: specifier: ^4.2.0 - version: 4.2.0(@types/react@19.2.9)(dexie@4.2.1)(react@19.2.3) + version: 4.2.0(@types/react@19.2.13)(dexie@4.3.0)(react@19.2.4) dotenv: - specifier: ^17.2.3 - version: 17.2.3 + specifier: ^17.2.4 + version: 17.2.4 drizzle-kit: - specifier: ^0.31.8 - version: 0.31.8 + specifier: ^0.31.9 + version: 0.31.9 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) + version: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0) epubjs: specifier: ^0.3.93 version: 0.3.93 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 + ffprobe-static: + specifier: ^3.1.0 + version: 3.1.0 howler: specifier: ^2.2.4 version: 2.2.4 lru-cache: - specifier: ^11.2.4 - version: 11.2.4 + specifier: ^11.2.6 + version: 11.2.6 next: - specifier: ^15.5.9 - version: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^15.5.12 + version: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: - specifier: ^6.16.0 - version: 6.16.0(zod@4.3.6) + specifier: ^6.21.0 + version: 6.21.0(zod@4.3.6) pdfjs-dist: specifier: 4.8.69 version: 4.8.69 pg: - specifier: ^8.17.2 - version: 8.17.2 + specifier: ^8.18.0 + version: 8.18.0 react: - specifier: ^19.2.3 - version: 19.2.3 + specifier: ^19.2.4 + version: 19.2.4 react-dnd: specifier: ^16.0.1 - version: 16.0.1(@types/node@20.19.30)(@types/react@19.2.9)(react@19.2.3) + version: 16.0.1(@types/node@20.19.33)(@types/react@19.2.13)(react@19.2.4) react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 react-dom: - specifier: ^19.2.3 - version: 19.2.3(react@19.2.3) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) react-dropzone: - specifier: ^14.3.8 - version: 14.3.8(react@19.2.3) + specifier: ^14.4.1 + version: 14.4.1(react@19.2.4) react-hot-toast: specifier: ^2.6.0 - version: 2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.9)(react@19.2.3) + version: 10.1.0(@types/react@19.2.13)(react@19.2.4) react-pdf: specifier: ^9.2.1 - version: 9.2.1(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 9.2.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-reader: specifier: ^2.0.15 - version: 2.0.15(react@19.2.3) + version: 2.0.15(react@19.2.4) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -120,32 +126,35 @@ importers: specifier: ^3.3.3 version: 3.3.3 '@playwright/test': - specifier: ^1.58.0 - version: 1.58.0 + specifier: ^1.58.2 + version: 1.58.2 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/ffprobe-static': + specifier: ^2.0.3 + version: 2.0.3 '@types/node': - specifier: ^20.19.30 - version: 20.19.30 + specifier: ^20.19.33 + version: 20.19.33 '@types/pg': specifier: ^8.16.0 version: 8.16.0 '@types/react': - specifier: ^19.2.9 - version: 19.2.9 + specifier: ^19.2.13 + version: 19.2.13 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.9) + version: 19.2.3(@types/react@19.2.13) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@1.21.7) eslint-config-next: - specifier: ^15.5.9 - version: 15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^15.5.12 + version: 15.5.12(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -185,8 +194,8 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.985.0': - resolution: {integrity: sha512-S9TqjzzZEEIKBnC7yFpvqM7CG9ALpY5qhQ5BnDBJtdG20NoGpjKLGUUfD2wmZItuhbrcM4Z8c6m6Fg0XYIOVvw==} + '@aws-sdk/client-s3@3.987.0': + resolution: {integrity: sha512-9nLbDIjqdiDkJk8hrAW8jP51bRXjD0+2J3lnCAy+N2G4BDoQuN09+iQF2chF/9BJ/hTk5Ldm2beaO8G2PM1cyw==} engines: {node: '>=20.0.0'} '@aws-sdk/client-sso@3.985.0': @@ -281,12 +290,12 @@ packages: resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.985.0': - resolution: {integrity: sha512-lPnf977GFM4cMLJ7X+ThktKMe/0CXIfX+wz1z+sUT7yagPL2IRyiNUPFZ0VTEGBo1gRhHEDPWy6yzk8WWRFsvg==} + '@aws-sdk/s3-request-presigner@3.987.0': + resolution: {integrity: sha512-XHf9ZQOgsdzBhfFhMM+wFITnd1M3OqMVUEdIrciSS8aFOg+WtQEcR2GcMs+Sj5whmO4XOrUMFuDdCgAwvdq0tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.985.0': - resolution: {integrity: sha512-W6hTSOPiSbh4IdTYVxN7xHjpCh0qvfQU1GKGBzGQm0ZEIOaMmWqiDEvFfyGYKmfBvumT8vHKxQRTX0av9omtIg==} + '@aws-sdk/signature-v4-multi-region@3.987.0': + resolution: {integrity: sha512-5kVC6x6+2NO+/NIXWJwN68+8cvqREsoE+tFOMyZWj2fg3EWzCnTGVIFd7hSJZJT2WiP5LqcrdEoFyXtfDta1hg==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.985.0': @@ -305,6 +314,10 @@ packages: resolution: {integrity: sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.987.0': + resolution: {integrity: sha512-rZnZwDq7Pn+TnL0nyS6ryAhpqTZtLtHbJaqfxuHlDX3v/bq0M7Ch/V3qF9dZWaGgsJ2H9xn7/vFOxlnL4fBMcQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.3': resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} engines: {node: '>=20.0.0'} @@ -337,8 +350,8 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.17': - resolution: {integrity: sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug==} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} peerDependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -347,10 +360,10 @@ packages: kysely: ^0.28.5 nanostores: ^1.0.1 - '@better-auth/telemetry@1.4.17': - resolution: {integrity: sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA==} + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} peerDependencies: - '@better-auth/core': 1.4.17 + '@better-auth/core': 1.4.18 '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -358,6 +371,10 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -704,14 +721,14 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - '@floating-ui/react-dom@2.1.6': - resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -901,56 +918,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@15.5.9': - resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==} + '@next/env@15.5.12': + resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} - '@next/eslint-plugin-next@15.5.9': - resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==} + '@next/eslint-plugin-next@15.5.12': + resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==} - '@next/swc-darwin-arm64@15.5.7': - resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} + '@next/swc-darwin-arm64@15.5.12': + resolution: {integrity: sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.7': - resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} + '@next/swc-darwin-x64@15.5.12': + resolution: {integrity: sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.7': - resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} + '@next/swc-linux-arm64-gnu@15.5.12': + resolution: {integrity: sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.7': - resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} + '@next/swc-linux-arm64-musl@15.5.12': + resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.7': - resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} + '@next/swc-linux-x64-gnu@15.5.12': + resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.7': - resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} + '@next/swc-linux-x64-musl@15.5.12': + resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.7': - resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} + '@next/swc-win32-arm64-msvc@15.5.12': + resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.7': - resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} + '@next/swc-win32-x64-msvc@15.5.12': + resolution: {integrity: sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -979,8 +996,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@playwright/test@1.58.0': - resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true @@ -993,14 +1010,14 @@ packages: prisma: optional: true - '@react-aria/focus@3.21.3': - resolution: {integrity: sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==} + '@react-aria/focus@3.21.4': + resolution: {integrity: sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/interactions@3.26.0': - resolution: {integrity: sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==} + '@react-aria/interactions@3.27.0': + resolution: {integrity: sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1011,8 +1028,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/utils@3.32.0': - resolution: {integrity: sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==} + '@react-aria/utils@3.33.0': + resolution: {integrity: sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1034,8 +1051,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/shared@3.32.1': - resolution: {integrity: sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==} + '@react-types/shared@3.33.0': + resolution: {integrity: sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1061,8 +1078,8 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.22.1': - resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.8': @@ -1125,12 +1142,12 @@ packages: resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.13': - resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==} + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.30': - resolution: {integrity: sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==} + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} engines: {node: '>=18.0.0'} '@smithy/middleware-serde@4.2.9': @@ -1145,8 +1162,8 @@ packages: resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.9': - resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==} + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.2.8': @@ -1177,8 +1194,8 @@ packages: resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.11.2': - resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==} + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} engines: {node: '>=18.0.0'} '@smithy/types@4.12.0': @@ -1213,12 +1230,12 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.29': - resolution: {integrity: sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==} + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.32': - resolution: {integrity: sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==} + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.2.8': @@ -1237,8 +1254,8 @@ packages: resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.11': - resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==} + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.0': @@ -1299,6 +1316,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/ffprobe-static@2.0.3': + resolution: {integrity: sha512-86Q8dIzhjknxA6935ZcVLkVU3t6bP+F08mdriWZk2+3UqjxIbT3QEADjbO5Yq8826cVs/aZBREVZ7jUoSTgHiw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1317,8 +1337,11 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.30': - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} @@ -1328,8 +1351,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.9': - resolution: {integrity: sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==} + '@types/react@19.2.13': + resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1340,63 +1363,63 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.53.1': - resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.53.1 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.53.1': - resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.53.1': - resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.53.1': - resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.53.1': - resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.53.1': - resolution: {integrity: sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.53.1': - resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.53.1': - resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.53.1': - resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.53.1': - resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1537,6 +1560,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1625,8 +1652,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-auth@1.4.17: - resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==} + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1709,8 +1736,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bowser@2.13.1: - resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1748,13 +1775,16 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001766: - resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} canvas@3.2.1: resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==} engines: {node: ^18.12.0 || >= 20.9.0} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1788,8 +1818,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cmpstr@3.2.0: - resolution: {integrity: sha512-ZNAEZBm+dfAA103DrGWg87benkl64PHAqRV0UL8xsHVMPqYcEq9WoO5hghQyVRKYiK3aS4T7/kH5TVz+ID5Fiw==} + cmpstr@3.2.1: + resolution: {integrity: sha512-BgOb4IfBTxZ8/VdAGlhmhTv3Um9YJTXrqbkGzkhEJiZ55+W6SbMJL+HiCBZh4TMdzJ45sYrXlyf9KU47QGpeqA==} engines: {node: '>=18.0.0'} color-convert@2.0.1: @@ -1813,6 +1843,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} @@ -1910,8 +1944,8 @@ packages: dexie: '>=4.2.0-alpha.1 <5.0.0' react: '>=16' - dexie@4.2.1: - resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dexie@4.3.0: + resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==} didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1926,12 +1960,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} - drizzle-kit@0.31.8: - resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} hasBin: true drizzle-orm@0.45.1: @@ -2043,6 +2077,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + epubjs@0.3.93: resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} @@ -2112,8 +2150,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@15.5.9: - resolution: {integrity: sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==} + eslint-config-next@15.5.12: + resolution: {integrity: sha512-ktW3XLfd+ztEltY5scJNjxjHwtKWk6vU2iwzZqSN09UsbBmMeE/cVlJ1yESg6Yx5LW7p/Z8WzUAgYXGLEmGIpg==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -2281,6 +2319,13 @@ packages: picomatch: optional: true + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + + ffprobe-static@3.1.0: + resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2350,8 +2395,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2427,6 +2472,13 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2635,8 +2687,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.28.10: - resolution: {integrity: sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA==} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} language-subtag-registry@0.3.23: @@ -2683,8 +2735,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} make-cancellable-promise@1.3.2: @@ -2894,8 +2946,8 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - next@15.5.9: - resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==} + next@15.5.12: + resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2965,8 +3017,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openai@6.16.0: - resolution: {integrity: sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==} + openai@6.21.0: + resolution: {integrity: sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -3000,6 +3052,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3028,8 +3083,8 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.10.1: - resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -3047,8 +3102,8 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.17.2: - resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -3078,13 +3133,13 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - playwright-core@1.58.0: - resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.58.0: - resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -3175,6 +3230,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3213,13 +3272,13 @@ packages: '@types/react': optional: true - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.2.3 + react: ^19.2.4 - react-dropzone@14.3.8: - resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} + react-dropzone@14.4.1: + resolution: {integrity: sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==} engines: {node: '>= 10.13'} peerDependencies: react: '>= 16.8 || 18.0.0' @@ -3258,8 +3317,8 @@ packages: peerDependencies: react: ^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -3350,8 +3409,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -3594,6 +3653,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3744,7 +3806,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.985.0': + '@aws-sdk/client-s3@3.987.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -3762,13 +3824,13 @@ snapshots: '@aws-sdk/middleware-ssec': 3.972.3 '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/signature-v4-multi-region': 3.987.0 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.985.0 + '@aws-sdk/util-endpoints': 3.987.0 '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/eventstream-serde-browser': 4.2.8 '@smithy/eventstream-serde-config-resolver': 4.3.8 '@smithy/eventstream-serde-node': 4.2.8 @@ -3779,25 +3841,25 @@ snapshots: '@smithy/invalid-dependency': 4.2.8 '@smithy/md5-js': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 @@ -3819,26 +3881,26 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -3851,12 +3913,12 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.1 '@aws-sdk/xml-builder': 3.972.4 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.8 @@ -3881,12 +3943,12 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.972.5': @@ -4002,7 +4064,7 @@ snapshots: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4038,15 +4100,15 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-config-provider': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4061,7 +4123,7 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.985.0 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -4081,26 +4143,26 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4117,18 +4179,18 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.985.0': + '@aws-sdk/s3-request-presigner@3.987.0': dependencies: - '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/signature-v4-multi-region': 3.987.0 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-format-url': 3.972.3 - '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-endpoint': 4.4.14 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.985.0': + '@aws-sdk/signature-v4-multi-region@3.987.0': dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.7 '@aws-sdk/types': 3.973.1 @@ -4166,6 +4228,14 @@ snapshots: '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.987.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -4181,7 +4251,7 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 - bowser: 2.13.1 + bowser: 2.14.1 tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.972.5': @@ -4202,20 +4272,20 @@ snapshots: '@babel/runtime@7.28.6': {} - '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)': + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 better-call: 1.1.8(zod@4.3.6) jose: 6.1.3 - kysely: 0.28.10 + kysely: 0.28.11 nanostores: 1.1.0 zod: 4.3.6 - '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -4223,6 +4293,13 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.8.1': @@ -4249,7 +4326,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.0 + get-tsconfig: 4.13.6 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -4441,40 +4518,40 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.3': + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.4': + '@floating-ui/dom@1.7.5': dependencies: - '@floating-ui/core': 1.7.3 + '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.4 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@floating-ui/react@0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@floating-ui/utils': 0.2.10 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tabbable: 6.4.0 '@floating-ui/utils@0.2.10': {} - '@headlessui/react@2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/focus': 3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - use-sync-external-store: 1.6.0(react@19.2.3) + '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) '@humanfs/core@0.19.1': {} @@ -4605,34 +4682,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@15.5.9': {} + '@next/env@15.5.12': {} - '@next/eslint-plugin-next@15.5.9': + '@next/eslint-plugin-next@15.5.12': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.7': + '@next/swc-darwin-arm64@15.5.12': optional: true - '@next/swc-darwin-x64@15.5.7': + '@next/swc-darwin-x64@15.5.12': optional: true - '@next/swc-linux-arm64-gnu@15.5.7': + '@next/swc-linux-arm64-gnu@15.5.12': optional: true - '@next/swc-linux-arm64-musl@15.5.7': + '@next/swc-linux-arm64-musl@15.5.12': optional: true - '@next/swc-linux-x64-gnu@15.5.7': + '@next/swc-linux-x64-gnu@15.5.12': optional: true - '@next/swc-linux-x64-musl@15.5.7': + '@next/swc-linux-x64-musl@15.5.12': optional: true - '@next/swc-win32-arm64-msvc@15.5.7': + '@next/swc-win32-arm64-msvc@15.5.12': optional: true - '@next/swc-win32-x64-msvc@15.5.7': + '@next/swc-win32-x64-msvc@15.5.12': optional: true '@noble/ciphers@2.1.1': {} @@ -4653,48 +4730,48 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@playwright/test@1.58.0': + '@playwright/test@1.58.2': dependencies: - playwright: 1.58.0 + playwright: 1.58.2 '@prisma/client@5.22.0': optional: true - '@react-aria/focus@3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/focus@3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 clsx: 2.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@react-aria/interactions@3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/interactions@3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.3) - '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@react-aria/ssr@3.9.10(react@19.2.3)': + '@react-aria/ssr@3.9.10(react@19.2.4)': dependencies: '@swc/helpers': 0.5.18 - react: 19.2.3 + react: 19.2.4 - '@react-aria/utils@3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/utils@3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.3) + '@react-aria/ssr': 3.9.10(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.3) - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-stately/utils': 3.11.0(react@19.2.4) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 clsx: 2.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@react-dnd/asap@5.0.2': {} @@ -4706,14 +4783,14 @@ snapshots: dependencies: '@swc/helpers': 0.5.18 - '@react-stately/utils@3.11.0(react@19.2.3)': + '@react-stately/utils@3.11.0(react@19.2.4)': dependencies: '@swc/helpers': 0.5.18 - react: 19.2.3 + react: 19.2.4 - '@react-types/shared@3.32.1(react@19.2.3)': + '@react-types/shared@3.33.0(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 '@rtsao/scc@1.1.0': {} @@ -4742,7 +4819,7 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/core@3.22.1': + '@smithy/core@3.23.0': dependencies: '@smithy/middleware-serde': 4.2.9 '@smithy/protocol-http': 5.3.8 @@ -4750,7 +4827,7 @@ snapshots: '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/uuid': 1.1.0 tslib: 2.8.1 @@ -4846,9 +4923,9 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.13': + '@smithy/middleware-endpoint@4.4.14': dependencies: - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/middleware-serde': 4.2.9 '@smithy/node-config-provider': 4.3.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -4857,12 +4934,12 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.30': + '@smithy/middleware-retry@4.4.31': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4887,7 +4964,7 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.9': + '@smithy/node-http-handler@4.4.10': dependencies: '@smithy/abort-controller': 4.2.8 '@smithy/protocol-http': 5.3.8 @@ -4936,14 +5013,14 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@4.11.2': + '@smithy/smithy-client@4.11.3': dependencies: - '@smithy/core': 3.22.1 - '@smithy/middleware-endpoint': 4.4.13 + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 '@smithy/middleware-stack': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 '@smithy/types@4.12.0': @@ -4984,20 +5061,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.29': + '@smithy/util-defaults-mode-browser@4.3.30': dependencies: '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.32': + '@smithy/util-defaults-mode-node@4.2.33': dependencies: '@smithy/config-resolver': 4.4.6 '@smithy/credential-provider-imds': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -5022,10 +5099,10 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.11': + '@smithy/util-stream@4.5.12': dependencies: '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-buffer-from': 4.2.0 @@ -5072,11 +5149,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19 - '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/virtual-core': 3.13.18 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@tanstack/virtual-core@3.13.18': {} @@ -5087,7 +5164,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 20.19.30 + '@types/node': 20.19.33 '@types/debug@4.1.12': dependencies: @@ -5099,6 +5176,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/ffprobe-static@2.0.3': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5115,21 +5194,23 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.30': + '@types/node@10.17.60': {} + + '@types/node@20.19.33': dependencies: undici-types: 6.21.0 '@types/pg@8.16.0': dependencies: - '@types/node': 20.19.30 + '@types/node': 20.19.33 pg-protocol: 1.11.0 pg-types: 2.2.0 - '@types/react-dom@19.2.3(@types/react@19.2.9)': + '@types/react-dom@19.2.3(@types/react@19.2.13)': dependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 - '@types/react@19.2.9': + '@types/react@19.2.13': dependencies: csstype: 3.2.3 @@ -5139,14 +5220,14 @@ snapshots: '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 @@ -5155,41 +5236,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) - '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.53.1': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5197,37 +5278,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.53.1': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.53.1': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} @@ -5291,10 +5372,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 + next: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 '@xmldom/xmldom@0.9.8': {} @@ -5304,6 +5385,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5415,10 +5502,10 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + better-auth@1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -5426,18 +5513,18 @@ snapshots: better-call: 1.1.8(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 - kysely: 0.28.10 + kysely: 0.28.11 nanostores: 1.1.0 zod: 4.3.6 optionalDependencies: '@prisma/client': 5.22.0 better-sqlite3: 12.6.2 - drizzle-kit: 0.31.8 - drizzle-orm: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) - next: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - pg: 8.17.2 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + drizzle-kit: 0.31.9 + drizzle-orm: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0) + next: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: 8.18.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) better-call@1.1.8(zod@4.3.6): dependencies: @@ -5465,7 +5552,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bowser@2.13.1: {} + bowser@2.14.1: {} brace-expansion@1.1.12: dependencies: @@ -5508,7 +5595,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001766: {} + caniuse-lite@1.0.30001769: {} canvas@3.2.1: dependencies: @@ -5516,6 +5603,8 @@ snapshots: prebuild-install: 7.1.3 optional: true + caseless@0.12.0: {} + ccount@2.0.1: {} chalk@4.1.2: @@ -5549,7 +5638,7 @@ snapshots: clsx@2.1.1: {} - cmpstr@3.2.0: {} + cmpstr@3.2.1: {} color-convert@2.0.1: dependencies: @@ -5569,6 +5658,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + core-js@3.48.0: {} core-util-is@1.0.3: {} @@ -5650,13 +5746,13 @@ snapshots: dependencies: dequal: 2.0.3 - dexie-react-hooks@4.2.0(@types/react@19.2.9)(dexie@4.2.1)(react@19.2.3): + dexie-react-hooks@4.2.0(@types/react@19.2.13)(dexie@4.3.0)(react@19.2.4): dependencies: - '@types/react': 19.2.9 - dexie: 4.2.1 - react: 19.2.3 + '@types/react': 19.2.13 + dexie: 4.3.0 + react: 19.2.4 - dexie@4.2.1: {} + dexie@4.3.0: {} didyoumean@1.2.2: {} @@ -5672,9 +5768,9 @@ snapshots: dependencies: esutils: 2.0.3 - dotenv@17.2.3: {} + dotenv@17.2.4: {} - drizzle-kit@0.31.8: + drizzle-kit@0.31.9: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 @@ -5683,14 +5779,14 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2): + drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0): optionalDependencies: '@prisma/client': 5.22.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.16.0 better-sqlite3: 12.6.2 - kysely: 0.28.10 - pg: 8.17.2 + kysely: 0.28.11 + pg: 8.18.0 dunder-proto@1.0.1: dependencies: @@ -5708,6 +5804,8 @@ snapshots: dependencies: once: 1.4.0 + env-paths@2.2.1: {} + epubjs@0.3.93: dependencies: '@types/localforage': empty-module@0.0.2 @@ -5904,16 +6002,16 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + eslint-config-next@15.5.12(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 15.5.9 + '@next/eslint-plugin-next': 15.5.12 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) @@ -5937,28 +6035,28 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) - get-tsconfig: 4.13.0 + get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5969,7 +6067,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -5981,7 +6079,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6156,6 +6254,17 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + ffmpeg-static@5.3.0: + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1 + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + + ffprobe-static@3.1.0: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6233,7 +6342,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -6316,6 +6425,17 @@ snapshots: html-url-attributes@3.0.1: {} + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6379,7 +6499,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} @@ -6525,7 +6645,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.28.10: {} + kysely@0.28.11: {} language-subtag-registry@0.3.23: {} @@ -6568,7 +6688,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.4: {} + lru-cache@11.2.6: {} make-cancellable-promise@1.3.2: {} @@ -6733,9 +6853,9 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - merge-refs@1.3.0(@types/react@19.2.9): + merge-refs@1.3.0(@types/react@19.2.13): optionalDependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 merge2@1.4.1: {} @@ -6969,25 +7089,25 @@ snapshots: next-tick@1.1.0: {} - next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 15.5.9 + '@next/env': 15.5.12 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001766 + caniuse-lite: 1.0.30001769 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.7 - '@next/swc-darwin-x64': 15.5.7 - '@next/swc-linux-arm64-gnu': 15.5.7 - '@next/swc-linux-arm64-musl': 15.5.7 - '@next/swc-linux-x64-gnu': 15.5.7 - '@next/swc-linux-x64-musl': 15.5.7 - '@next/swc-win32-arm64-msvc': 15.5.7 - '@next/swc-win32-x64-msvc': 15.5.7 - '@playwright/test': 1.58.0 + '@next/swc-darwin-arm64': 15.5.12 + '@next/swc-darwin-x64': 15.5.12 + '@next/swc-linux-arm64-gnu': 15.5.12 + '@next/swc-linux-arm64-musl': 15.5.12 + '@next/swc-linux-x64-gnu': 15.5.12 + '@next/swc-linux-x64-musl': 15.5.12 + '@next/swc-win32-arm64-msvc': 15.5.12 + '@next/swc-win32-x64-msvc': 15.5.12 + '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -6995,7 +7115,7 @@ snapshots: node-abi@3.87.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 node-addon-api@7.1.1: optional: true @@ -7050,7 +7170,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.16.0(zod@4.3.6): + openai@6.21.0(zod@4.3.6): optionalDependencies: zod: 4.3.6 @@ -7083,6 +7203,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cache-control@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7112,13 +7234,13 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.10.1: {} + pg-connection-string@2.11.0: {} pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.17.2): + pg-pool@3.11.0(pg@8.18.0): dependencies: - pg: 8.17.2 + pg: 8.18.0 pg-protocol@1.11.0: {} @@ -7130,10 +7252,10 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.17.2: + pg@8.18.0: dependencies: - pg-connection-string: 2.10.1 - pg-pool: 3.11.0(pg@8.17.2) + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) pg-protocol: 1.11.0 pg-types: 2.2.0 pgpass: 1.0.5 @@ -7154,11 +7276,11 @@ snapshots: pirates@4.0.7: {} - playwright-core@1.58.0: {} + playwright-core@1.58.2: {} - playwright@1.58.0: + playwright@1.58.2: dependencies: - playwright-core: 1.58.0 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 @@ -7241,6 +7363,8 @@ snapshots: process-nextick-args@2.0.1: {} + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7269,49 +7393,49 @@ snapshots: dependencies: dnd-core: 16.0.1 - react-dnd@16.0.1(@types/node@20.19.30)(@types/react@19.2.9)(react@19.2.3): + react-dnd@16.0.1(@types/node@20.19.33)(@types/react@19.2.13)(react@19.2.4): dependencies: '@react-dnd/invariant': 4.0.2 '@react-dnd/shallowequal': 4.0.2 dnd-core: 16.0.1 fast-deep-equal: 3.1.3 hoist-non-react-statics: 3.3.2 - react: 19.2.3 + react: 19.2.4 optionalDependencies: - '@types/node': 20.19.30 - '@types/react': 19.2.9 + '@types/node': 20.19.33 + '@types/react': 19.2.13 - react-dom@19.2.3(react@19.2.3): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 scheduler: 0.27.0 - react-dropzone@14.3.8(react@19.2.3): + react-dropzone@14.4.1(react@19.2.4): dependencies: attr-accept: 2.2.5 file-selector: 2.1.2 prop-types: 15.8.1 - react: 19.2.3 + react: 19.2.4 - react-hot-toast@2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-hot-toast@2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: csstype: 3.2.3 goober: 2.1.18(csstype@3.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@19.2.9)(react@19.2.3): + react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.9 + '@types/react': 19.2.13 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.3 + react: 19.2.4 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -7320,33 +7444,33 @@ snapshots: transitivePeerDependencies: - supports-color - react-pdf@9.2.1(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-pdf@9.2.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: clsx: 2.1.1 dequal: 2.0.3 make-cancellable-promise: 1.3.2 make-event-props: 1.6.2 - merge-refs: 1.3.0(@types/react@19.2.9) + merge-refs: 1.3.0(@types/react@19.2.13) pdfjs-dist: 4.8.69 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 warning: 4.0.3 optionalDependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 - react-reader@2.0.15(react@19.2.3): + react-reader@2.0.15(react@19.2.4): dependencies: epubjs: 0.3.93 - react-swipeable: 7.0.2(react@19.2.3) + react-swipeable: 7.0.2(react@19.2.4) transitivePeerDependencies: - react - react-swipeable@7.0.2(react@19.2.3): + react-swipeable@7.0.2(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 - react@19.2.3: {} + react@19.2.4: {} read-cache@1.0.0: dependencies: @@ -7481,7 +7605,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} set-cookie-parser@2.7.2: {} @@ -7513,7 +7637,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -7682,10 +7806,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 - react: 19.2.3 + react: 19.2.4 sucrase@3.35.1: dependencies: @@ -7831,6 +7955,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript@5.9.3: {} unbox-primitive@1.1.0: @@ -7903,9 +8029,9 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.3): + use-sync-external-store@1.6.0(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 util-deprecate@1.0.2: {} diff --git a/scripts/migrate-fs-v2.mjs b/scripts/migrate-fs-v2.mjs new file mode 100644 index 0000000..97cb3d9 --- /dev/null +++ b/scripts/migrate-fs-v2.mjs @@ -0,0 +1,919 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import * as dotenv from 'dotenv'; +import { + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; + +const require = createRequire(import.meta.url); +const { Pool } = require('pg'); +const BetterSqlite3 = require('better-sqlite3'); +const ffprobeStatic = require('ffprobe-static'); + +const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); +const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); +const UNCLAIMED_USER_ID = 'unclaimed'; + +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; + +function loadEnvFiles() { + const envPath = path.join(process.cwd(), '.env'); + const envLocalPath = path.join(process.cwd(), '.env.local'); + if (fs.existsSync(envPath)) dotenv.config({ path: envPath }); + if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true }); +} + +function parseBool(value, fallback = false) { + if (value == null || String(value).trim() === '') return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function parseArgs(argv) { + const args = { + dryRun: false, + deleteLocal: false, + namespace: null, + }; + + for (let i = 0; i < argv.length; i += 1) { + const raw = argv[i]; + if (raw === '--dry-run' && argv[i + 1]) { + args.dryRun = parseBool(argv[i + 1], true); + i += 1; + continue; + } + if (raw.startsWith('--dry-run=')) { + args.dryRun = parseBool(raw.slice('--dry-run='.length), true); + continue; + } + if (raw === '--delete-local' && argv[i + 1]) { + args.deleteLocal = parseBool(argv[i + 1], true); + i += 1; + continue; + } + if (raw.startsWith('--delete-local=')) { + args.deleteLocal = parseBool(raw.slice('--delete-local='.length), true); + continue; + } + if (raw === '--namespace' && argv[i + 1]) { + args.namespace = sanitizeNamespace(argv[i + 1]); + i += 1; + continue; + } + if (raw.startsWith('--namespace=')) { + args.namespace = sanitizeNamespace(raw.slice('--namespace='.length)); + } + } + + return args; +} + +function sanitizeNamespace(namespace) { + if (!namespace) return null; + const safe = String(namespace).trim(); + if (!safe || !SAFE_NAMESPACE_REGEX.test(safe)) return null; + return safe; +} + +function applyNamespacePath(baseDir, namespace) { + if (!namespace) return baseDir; + const resolved = path.resolve(baseDir, namespace); + if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; + return resolved; +} + +function getUnclaimedUserIdForNamespace(namespace) { + if (!namespace) return UNCLAIMED_USER_ID; + return `${UNCLAIMED_USER_ID}::${namespace}`; +} + +function normalizePrefix(prefix) { + const base = String(prefix || 'openreader').trim(); + if (!base) return 'openreader'; + return base.replace(/^\/+|\/+$/g, ''); +} + +function parseS3ConfigFromEnv() { + const bucket = process.env.S3_BUCKET?.trim(); + const region = process.env.S3_REGION?.trim(); + const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim(); + const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim(); + const endpoint = process.env.S3_ENDPOINT?.trim(); + + if (!bucket || !region || !accessKeyId || !secretAccessKey) { + throw new Error('S3 is not configured. Required env vars: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.'); + } + + return { + bucket, + region, + endpoint: endpoint || undefined, + accessKeyId, + secretAccessKey, + forcePathStyle: parseBool(process.env.S3_FORCE_PATH_STYLE, false), + prefix: normalizePrefix(process.env.S3_PREFIX), + }; +} + +function createS3Client(config) { + return new S3Client({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle: config.forcePathStyle, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); +} + +function isPreconditionFailed(error) { + if (!error || typeof error !== 'object') return false; + return error?.$metadata?.httpStatusCode === 412 || error?.name === 'PreconditionFailed'; +} + +function documentKey(s3Config, id, namespace) { + if (!DOCUMENT_ID_REGEX.test(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const nsSegment = namespace ? `ns/${namespace}/` : ''; + return `${s3Config.prefix}/documents_v1/${nsSegment}${id}`; +} + +function audiobookKey(s3Config, bookId, userId, fileName, namespace) { + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) throw new Error(`Invalid audiobook id: ${bookId}`); + if (!userId) throw new Error('Missing user id for audiobook key'); + if (!fileName || fileName.includes('/') || fileName.includes('\\')) throw new Error(`Invalid audiobook file name: ${fileName}`); + const nsSegment = namespace ? `ns/${namespace}/` : ''; + return `${s3Config.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(String(userId))}/${bookId}-audiobook/${fileName}`; +} + +async function putObjectIfMissing(s3Client, s3Config, key, body, contentType) { + await s3Client.send(new PutObjectCommand({ + Bucket: s3Config.bucket, + Key: key, + Body: body, + ContentType: contentType, + IfNoneMatch: '*', + })); +} + +function isLegacyDocumentMetadata(value) { + if (!value || typeof value !== 'object') return false; + const v = value; + return typeof v.id === 'string' + && typeof v.name === 'string' + && typeof v.size === 'number' + && typeof v.lastModified === 'number' + && typeof v.type === 'string'; +} + +function isValidDocumentId(id) { + return DOCUMENT_ID_REGEX.test(id); +} + +function extractIdFromFileName(fileName) { + const match = /^([a-f0-9]{64})__/i.exec(fileName); + if (!match) return null; + const id = match[1].toLowerCase(); + return isValidDocumentId(id) ? id : null; +} + +function decodeNameFromFileName(fileName, id) { + const prefix = `${id}__`; + if (!fileName.startsWith(prefix)) return fileName; + const encoded = fileName.slice(prefix.length); + try { + return decodeURIComponent(encoded); + } catch { + return fileName; + } +} + +function sniffBinaryDocumentType(bytes) { + if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') { + return 'pdf'; + } + + const isZip = bytes.length >= 4 + && bytes[0] === 0x50 + && bytes[1] === 0x4b + && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) + && (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08); + if (!isZip) return null; + + const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1'); + if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) return 'epub'; + if (probe.includes('[Content_Types].xml') && probe.includes('word/')) return 'docx'; + return null; +} + +function toDocumentTypeFromName(name) { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + if (ext === '.docx') return 'docx'; + return 'html'; +} + +function safeDocumentName(rawName, fallback) { + const baseName = path.basename(rawName || fallback); + return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; +} + +function normalizeNameForType(name, id, type) { + if (type === 'html') return name; + const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx'; + if (name.toLowerCase().endsWith(expectedExt)) return name; + const base = name.replace(/\.bin$/i, ''); + return `${base || id}${expectedExt}`; +} + +function contentTypeForName(name) { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.epub') return 'application/epub+zip'; + if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8'; + if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8'; + return 'text/plain; charset=utf-8'; +} + +function contentTypeForDocument(type, name) { + if (type === 'pdf') return 'application/pdf'; + if (type === 'epub') return 'application/epub+zip'; + if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + return contentTypeForName(name); +} + +function sanitizeTagValue(value) { + return String(value || '').replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim(); +} + +function sanitizeFileStem(value) { + return sanitizeTagValue(value) + .replaceAll(/[\\/]/g, ' ') + .replaceAll(/[<>:"|?*\u0000]/g, '') + .replaceAll(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function encodeChapterFileName(index, title, format) { + const oneBased = String(index + 1).padStart(4, '0'); + const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`; + return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; +} + +function decodeChapterFileName(fileName) { + const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isInteger(oneBased) || oneBased <= 0) return null; + const format = match[3].toLowerCase(); + try { + const title = decodeURIComponent(match[2]); + return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format }; + } catch { + return { index: oneBased - 1, title: match[2], format }; + } +} + +function decodeChapterTitleTag(tag) { + const raw = sanitizeTagValue(tag); + if (!raw) return null; + const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null; + return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` }; +} + +function chooseBinary(preferred, bundled, envVarName, packageName) { + const envValue = preferred ? String(preferred).trim() : ''; + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + const bundledValue = bundled ? String(bundled).trim() : ''; + if (!bundledValue) { + throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`); + } + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + return bundledValue; +} + +function getFFprobePath() { + return chooseBinary(process.env.FFPROBE_BIN || null, ffprobeStatic?.path || null, 'FFPROBE_BIN', 'ffprobe-static'); +} + +async function ffprobeTitleTag(filePath) { + return new Promise((resolve) => { + const child = spawn(getFFprobePath(), [ + '-v', 'quiet', + '-print_format', 'json', + '-show_entries', 'format_tags=title', + filePath, + ]); + + let output = ''; + child.stdout.on('data', (data) => { output += data.toString(); }); + child.on('error', () => resolve(null)); + child.on('close', (code) => { + if (code !== 0) { + resolve(null); + return; + } + try { + const parsed = JSON.parse(output); + const title = parsed?.format?.tags?.title; + resolve(typeof title === 'string' ? title : null); + } catch { + resolve(null); + } + }); + }); +} + +function isPersistedAudiobookFileName(fileName) { + if (fileName === 'audiobook.meta.json') return true; + if (fileName === 'complete.mp3' || fileName === 'complete.m4b') return true; + if (/^complete\.(mp3|m4b)\.manifest\.json$/i.test(fileName)) return true; + return decodeChapterFileName(fileName) !== null; +} + +function isTransientAudiobookFileName(fileName) { + if (/^-?\d+-input\.mp3$/i.test(fileName)) return true; + if (/\.tmp\./i.test(fileName)) return true; + return false; +} + +function contentTypeForAudiobookFileName(fileName) { + if (fileName.endsWith('.mp3')) return 'audio/mpeg'; + if (fileName.endsWith('.m4b')) return 'audio/mp4'; + if (fileName.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} + +async function collectDocumentCandidates(docsDir, docstoreDir) { + const byId = new Map(); + let filesScanned = 0; + let skippedInvalid = 0; + + if (fs.existsSync(docsDir)) { + const entries = await fsp.readdir(docsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + filesScanned += 1; + const fullPath = path.join(docsDir, entry.name); + const bytes = await fsp.readFile(fullPath); + const st = await fsp.stat(fullPath); + + const extractedId = extractIdFromFileName(entry.name); + const id = extractedId ?? createHash('sha256').update(bytes).digest('hex'); + if (!isValidDocumentId(id)) { + skippedInvalid += 1; + continue; + } + + const inferredName = decodeNameFromFileName(entry.name, id); + const inferredType = toDocumentTypeFromName(inferredName); + const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; + const normalizedName = normalizeNameForType(inferredName, id, type); + const contentType = contentTypeForDocument(type, normalizedName); + const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now(); + + if (!byId.has(id)) { + byId.set(id, { + id, + name: normalizedName, + type, + size: bytes.length, + lastModified, + contentType, + bytes, + localPaths: new Set([fullPath]), + }); + } else { + byId.get(id).localPaths.add(fullPath); + } + } + } + + if (fs.existsSync(docstoreDir)) { + const entries = await fsp.readdir(docstoreDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const metadataPath = path.join(docstoreDir, entry.name); + let parsed; + try { + parsed = JSON.parse(await fsp.readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + + const contentPath = path.join(docstoreDir, `${parsed.id}.${parsed.type}`); + if (!fs.existsSync(contentPath)) continue; + + filesScanned += 1; + const bytes = await fsp.readFile(contentPath); + const st = await fsp.stat(contentPath); + const id = createHash('sha256').update(bytes).digest('hex'); + if (!isValidDocumentId(id)) { + skippedInvalid += 1; + continue; + } + + const fallbackName = `${id}.${parsed.type}`; + const normalizedInputName = safeDocumentName(parsed.name, fallbackName); + const inferredType = toDocumentTypeFromName(normalizedInputName); + const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; + const normalizedName = normalizeNameForType(normalizedInputName, id, type); + const contentType = contentTypeForDocument(type, normalizedName); + const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now(); + + if (!byId.has(id)) { + byId.set(id, { + id, + name: normalizedName, + type, + size: bytes.length, + lastModified, + contentType, + bytes, + localPaths: new Set([contentPath, metadataPath]), + }); + } else { + byId.get(id).localPaths.add(contentPath); + byId.get(id).localPaths.add(metadataPath); + } + } + } + + return { + candidates: Array.from(byId.values()).map((item) => ({ + ...item, + localPaths: Array.from(item.localPaths), + })), + filesScanned, + skippedInvalid, + }; +} + +async function collectAudiobookCandidates(audiobooksDir, docstoreDir) { + const stats = { + booksScanned: 0, + filesScanned: 0, + skippedTransient: 0, + }; + + const sourceDirs = new Map(); + + if (fs.existsSync(audiobooksDir)) { + const entries = await fsp.readdir(audiobooksDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue; + const bookId = entry.name.slice(0, -'-audiobook'.length); + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue; + sourceDirs.set(`${bookId}::${path.join(audiobooksDir, entry.name)}`, { + bookId, + dirPath: path.join(audiobooksDir, entry.name), + }); + } + } + + if (fs.existsSync(docstoreDir)) { + const entries = await fsp.readdir(docstoreDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue; + const bookId = entry.name.slice(0, -'-audiobook'.length); + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue; + sourceDirs.set(`${bookId}::${path.join(docstoreDir, entry.name)}`, { + bookId, + dirPath: path.join(docstoreDir, entry.name), + }); + } + } + + const grouped = new Map(); + for (const source of sourceDirs.values()) { + if (!grouped.has(source.bookId)) grouped.set(source.bookId, []); + grouped.get(source.bookId).push(source.dirPath); + } + + const books = []; + for (const [bookId, dirPaths] of grouped.entries()) { + stats.booksScanned += 1; + + const uploads = []; + const dedupedChapterByIndex = new Map(); + let title = 'Unknown Title'; + + for (const dirPath of dirPaths) { + const entries = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []); + const chapterMetaByIndex = new Map(); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === 'audiobook.meta.json') continue; + if (!entry.name.endsWith('.meta.json')) continue; + const metaPath = path.join(dirPath, entry.name); + try { + const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8')); + const idx = Number(raw?.index ?? entry.name.replace(/\.meta\.json$/i, '')); + const chapterTitle = typeof raw?.title === 'string' ? raw.title : null; + if (Number.isInteger(idx) && idx >= 0 && chapterTitle) { + chapterMetaByIndex.set(idx, chapterTitle); + } + } catch {} + } + + const unresolvedSha = []; + const usedIndices = new Set(); + const chapterUploads = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + const fileName = entry.name; + stats.filesScanned += 1; + + if (isTransientAudiobookFileName(fileName)) { + stats.skippedTransient += 1; + continue; + } + + const fullPath = path.join(dirPath, fileName); + const st = await fsp.stat(fullPath).catch(() => null); + const mtimeMs = Number(st?.mtimeMs ?? 0); + + const canonical = decodeChapterFileName(fileName); + if (canonical) { + const targetFileName = encodeChapterFileName(canonical.index, canonical.title, canonical.format); + chapterUploads.push({ + index: canonical.index, + title: canonical.title, + format: canonical.format, + targetFileName, + sourcePath: fullPath, + mtimeMs, + }); + usedIndices.add(canonical.index); + continue; + } + + if (isPersistedAudiobookFileName(fileName)) { + uploads.push({ + sourcePath: fullPath, + targetFileName: fileName, + contentType: contentTypeForAudiobookFileName(fileName), + }); + continue; + } + + const legacy = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(fileName); + if (legacy) { + const index = Number(legacy[1]); + const format = legacy[2].toLowerCase(); + const chapterTitle = chapterMetaByIndex.get(index) ?? `Chapter ${index + 1}`; + const targetFileName = encodeChapterFileName(index, chapterTitle, format); + chapterUploads.push({ + index, + title: chapterTitle, + format, + targetFileName, + sourcePath: fullPath, + mtimeMs, + }); + usedIndices.add(index); + continue; + } + + const sha = /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(fileName); + if (sha) { + unresolvedSha.push({ + sourcePath: fullPath, + format: fileName.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b', + mtimeMs, + }); + continue; + } + } + + unresolvedSha.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath)); + const nextIndex = () => { + let idx = 0; + while (usedIndices.has(idx)) idx += 1; + usedIndices.add(idx); + return idx; + }; + + for (const item of unresolvedSha) { + let decoded = null; + const titleTag = await ffprobeTitleTag(item.sourcePath); + if (titleTag) decoded = decodeChapterTitleTag(titleTag); + const index = decoded?.index ?? nextIndex(); + const chapterTitle = decoded?.title ?? `Chapter ${index + 1}`; + const targetFileName = encodeChapterFileName(index, chapterTitle, item.format); + chapterUploads.push({ + index, + title: chapterTitle, + format: item.format, + targetFileName, + sourcePath: item.sourcePath, + mtimeMs: item.mtimeMs, + }); + } + + for (const chapter of chapterUploads) { + uploads.push({ + sourcePath: chapter.sourcePath, + targetFileName: chapter.targetFileName, + contentType: contentTypeForAudiobookFileName(chapter.targetFileName), + }); + + const current = dedupedChapterByIndex.get(chapter.index); + if (!current || chapter.targetFileName > current.fileName || chapter.mtimeMs > current.mtimeMs) { + dedupedChapterByIndex.set(chapter.index, { + index: chapter.index, + title: chapter.title, + format: chapter.format, + fileName: chapter.targetFileName, + mtimeMs: chapter.mtimeMs, + }); + } + } + } + + const chapters = Array.from(dedupedChapterByIndex.values()) + .sort((a, b) => a.index - b.index) + .map((chapter) => ({ + index: chapter.index, + title: chapter.title, + format: chapter.format, + fileName: chapter.fileName, + })); + + if (chapters.length > 0) title = chapters[0].title || title; + + books.push({ + id: bookId, + title, + chapters, + uploads, + sourceDirs: dirPaths, + }); + } + + return { books, stats }; +} + +function openDatabase() { + if (process.env.POSTGRES_URL) { + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, + }); + return { + mode: 'pg', + async query(sql, values = []) { + return pool.query(sql, values); + }, + async close() { + await pool.end(); + }, + }; + } + + const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db'); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const sqlite = new BetterSqlite3(dbPath); + return { + mode: 'sqlite', + async query(sql, values = []) { + if (/^\s*select/i.test(sql)) { + const rows = sqlite.prepare(sql).all(...values); + return { rows, rowCount: rows.length }; + } + const info = sqlite.prepare(sql).run(...values); + return { rows: [], rowCount: Number(info.changes ?? 0) }; + }, + async close() { + sqlite.close(); + }, + }; +} + +async function migrateDatabaseRows(database, userId, docCandidates, audiobookBooks, dryRun) { + const mismatched = await database.query('SELECT COUNT(*) AS count FROM documents WHERE file_path <> id'); + const rowsUpdated = Number(mismatched.rows[0]?.count ?? mismatched.rows[0]?.COUNT ?? 0); + if (!dryRun && rowsUpdated > 0) { + await database.query('UPDATE documents SET file_path = id WHERE file_path <> id'); + } + + const existingDocs = database.mode === 'sqlite' + ? await database.query('SELECT id FROM documents WHERE user_id = ?', [userId]) + : await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]); + const existingDocIds = new Set(existingDocs.rows.map((row) => row.id)); + const toInsertDocs = docCandidates.filter((candidate) => !existingDocIds.has(candidate.id)); + + if (!dryRun) { + for (const candidate of toInsertDocs) { + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)', + [candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id], + ); + } else { + await database.query( + 'INSERT INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING', + [candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id], + ); + } + } + + for (const book of audiobookBooks) { + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO audiobooks (id, user_id, title, duration) VALUES (?, ?, ?, ?)', + [book.id, userId, book.title || 'Unknown Title', 0], + ); + } else { + await database.query( + 'INSERT INTO audiobooks (id, user_id, title, duration) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', + [book.id, userId, book.title || 'Unknown Title', 0], + ); + } + + for (const chapter of book.chapters) { + const chapterId = `${book.id}-${chapter.index}`; + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format], + ); + } else { + await database.query( + 'INSERT INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING', + [chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format], + ); + } + } + } + } + + return { + dbRowsUpdated: rowsUpdated, + dbRowsSeeded: toInsertDocs.length, + audiobookDbBooksSeeded: audiobookBooks.length, + audiobookDbChaptersSeeded: audiobookBooks.reduce((sum, book) => sum + book.chapters.length, 0), + }; +} + +async function main() { + loadEnvFiles(); + const { dryRun, deleteLocal, namespace } = parseArgs(process.argv.slice(2)); + const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace); + const docsDir = applyNamespacePath(DOCUMENTS_V1_DIR, namespace); + const audiobooksDir = applyNamespacePath(AUDIOBOOKS_V1_DIR, namespace); + const docstoreDir = applyNamespacePath(DOCSTORE_DIR, namespace); + + const s3Config = parseS3ConfigFromEnv(); + const s3Client = createS3Client(s3Config); + + const { candidates: documentCandidates, filesScanned, skippedInvalid } = await collectDocumentCandidates(docsDir, docstoreDir); + const { books: audiobookBooks, stats: audiobookStats } = await collectAudiobookCandidates(audiobooksDir, docstoreDir); + + let uploaded = 0; + let alreadyPresent = 0; + let deletedLocal = 0; + + for (const candidate of documentCandidates) { + if (!dryRun) { + try { + await putObjectIfMissing( + s3Client, + s3Config, + documentKey(s3Config, candidate.id, namespace), + candidate.bytes, + candidate.contentType, + ); + uploaded += 1; + } catch (error) { + if (isPreconditionFailed(error)) { + alreadyPresent += 1; + } else { + throw error; + } + } + } + + if (deleteLocal && !dryRun) { + for (const localPath of candidate.localPaths) { + const removed = await fsp.unlink(localPath).then(() => true).catch(() => false); + if (removed) deletedLocal += 1; + } + } + } + + let audiobookUploaded = 0; + let audiobookAlreadyPresent = 0; + let audiobookDeletedLocal = 0; + + for (const book of audiobookBooks) { + for (const upload of book.uploads) { + const bytes = await fsp.readFile(upload.sourcePath); + if (!dryRun) { + try { + await putObjectIfMissing( + s3Client, + s3Config, + audiobookKey(s3Config, book.id, unclaimedUserId, upload.targetFileName, namespace), + bytes, + upload.contentType, + ); + audiobookUploaded += 1; + } catch (error) { + if (isPreconditionFailed(error)) { + audiobookAlreadyPresent += 1; + } else { + throw error; + } + } + } + + if (deleteLocal && !dryRun) { + const removed = await fsp.unlink(upload.sourcePath).then(() => true).catch(() => false); + if (removed) audiobookDeletedLocal += 1; + } + } + + if (deleteLocal && !dryRun) { + for (const dirPath of book.sourceDirs) { + await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => {}); + } + } + } + + const database = openDatabase(); + try { + const dbStats = await migrateDatabaseRows( + database, + unclaimedUserId, + documentCandidates, + audiobookBooks, + dryRun, + ); + + const result = { + success: true, + dryRun, + deleteLocal, + docsDir, + audiobooksDir, + namespace, + filesScanned, + uploaded, + alreadyPresent, + skippedInvalid, + deletedLocal, + dbRowsUpdated: dbStats.dbRowsUpdated, + dbRowsSeeded: dbStats.dbRowsSeeded, + audiobookBooksScanned: audiobookStats.booksScanned, + audiobookFilesScanned: audiobookStats.filesScanned, + audiobookUploaded, + audiobookAlreadyPresent, + audiobookDeletedLocal, + audiobookSkippedTransient: audiobookStats.skippedTransient, + audiobookDbBooksSeeded: dbStats.audiobookDbBooksSeeded, + audiobookDbChaptersSeeded: dbStats.audiobookDbChaptersSeeded, + }; + + console.log(JSON.stringify(result, null, 2)); + } finally { + await database.close(); + } +} + +main().catch((error) => { + console.error('Error running v2 migration script:', error); + process.exit(1); +}); diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index a39df05..0c0cae5 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -28,6 +28,11 @@ function isTrue(value, defaultValue) { return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; } +function resolveBooleanEnv(env, key, defaultValue) { + const value = env[key]; + return isTrue(value, defaultValue); +} + function withDefault(value, fallback) { return value && value.trim() ? value.trim() : fallback; } @@ -193,6 +198,35 @@ function runDbMigrations(env) { } } +function runStorageMigrations(env) { + const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs'); + if (!fs.existsSync(migrateScript)) { + throw new Error(`Could not find storage migration script at ${migrateScript}`); + } + + console.log('Running storage migrations (v2)...'); + const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], { + env, + stdio: 'inherit', + }); + + if (migration.error) { + throw migration.error; + } + if (typeof migration.status === 'number' && migration.status !== 0) { + throw new Error(`Storage migrations failed with exit code ${migration.status}.`); + } +} + +function hasS3Config(env) { + return Boolean( + env.S3_BUCKET?.trim() + && env.S3_REGION?.trim() + && env.S3_ACCESS_KEY_ID?.trim() + && env.S3_SECRET_ACCESS_KEY?.trim() + ); +} + function sendSignal(child, signal, useProcessGroup) { if (!child) return false; if (useProcessGroup && process.platform !== 'win32' && typeof child.pid === 'number' && child.pid > 0) { @@ -291,7 +325,7 @@ async function main() { }); try { - const shouldRunDbMigrations = isTrue(runtimeEnv.RUN_DB_MIGRATIONS, true); + const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true); if (shouldRunDbMigrations) { runDbMigrations(runtimeEnv); } @@ -351,6 +385,15 @@ async function main() { console.log(`Embedded SeaweedFS is ready at ${endpoint}`); } + const shouldRunStorageMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_FS_MIGRATIONS', true); + if (shouldRunStorageMigrations) { + if (hasS3Config(runtimeEnv)) { + runStorageMigrations(runtimeEnv); + } else { + console.warn('Skipping storage migrations: S3 configuration is incomplete.'); + } + } + const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); appProc = child; const exitCode = await exitPromise; diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index f52924d..300199e 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,211 +1,196 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createReadStream, existsSync } from 'fs'; -import { readdir, unlink } from 'fs/promises'; -import { join } from 'path'; -import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; -import { findStoredChapterByIndex } from '@/lib/server/audiobook'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { and, eq, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { + deleteAudiobookObject, + getAudiobookObjectBuffer, + isMissingBlobError, + listAudiobookObjects, +} from '@/lib/server/audiobooks-blobstore'; +import { decodeChapterFileName } from '@/lib/server/audiobook'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookChapterIfMissingFile, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null { + const matches = fileNames + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + if (decoded.index !== index) return null; + return { fileName, title: decoded.title, format: decoded.format }; + }) + .filter((value): value is { fileName: string; title: string; format: 'mp3' | 'm4b' } => Boolean(value)) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + + return matches.at(-1) ?? null; +} + export async function GET(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); if (!bookId || !chapterIndexStr) { - return NextResponse.json( - { error: 'Missing bookId or chapterIndex parameter' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 }); } - const chapterIndex = parseInt(chapterIndexStr); - if (isNaN(chapterIndex)) { - return NextResponse.json( - { error: 'Invalid chapterIndex parameter' }, - { status: 400 } - ); - } - - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); + const chapterIndex = Number.parseInt(chapterIndexStr, 10); + if (!Number.isInteger(chapterIndex) || chapterIndex < 0) { + return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); } const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const chapter = findChapterFileNameByIndex( + objects.map((object) => object.fileName), + chapterIndex, ); - const dirExists = existsSync(intermediateDir); - if (!dirExists) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); + + if (!chapter) { + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, existingBook.userId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); - const chapterFileExists = !!chapter?.filePath && existsSync(chapter.filePath); - if (!chapterFileExists) { - await pruneAudiobookChapterIfMissingFile(bookId, existingBook.userId, chapterIndex, false); - return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); - } - - // Stream the chapter file - const stream = createReadStream(chapter.filePath); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, - cancel() { - stream.destroy(); + let buffer: Buffer; + try { + buffer = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace); + } catch (error) { + if (isMissingBlobError(error)) { + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, existingBook.userId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - }); + throw error; + } const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); - return new NextResponse(readableWebStream, { + return new NextResponse(streamBuffer(buffer), { headers: { 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`, 'Cache-Control': 'no-cache', }, }); - } catch (error) { console.error('Error downloading chapter:', error); - return NextResponse.json( - { error: 'Failed to download chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 }); } } export async function DELETE(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); if (!bookId || !chapterIndexStr) { - return NextResponse.json( - { error: 'Missing bookId or chapterIndex parameter' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 }); } - const chapterIndex = parseInt(chapterIndexStr, 10); - if (isNaN(chapterIndex)) { - return NextResponse.json( - { error: 'Invalid chapterIndex parameter' }, - { status: 400 } - ); - } - - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); + const chapterIndex = Number.parseInt(chapterIndexStr, 10); + if (!Number.isInteger(chapterIndex) || chapterIndex < 0) { + return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); } const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); - await ensureDbIndexed(); - - // Verify ownership and delete from DB with composite PK const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); + 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, storageUserId), - eq(audiobookChapters.chapterIndex, chapterIndex), - ), - ); + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, storageUserId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); - const intermediateDir = join( - getAudiobooksRootDir({ - userId: storageUserId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); + const objectNames = (await listAudiobookObjects(bookId, storageUserId, testNamespace)).map((object) => object.fileName); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; - const files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - if (!file.startsWith(chapterPrefix)) continue; - if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; - await unlink(join(intermediateDir, file)).catch(() => { }); + + for (const fileName of objectNames) { + if (!fileName.startsWith(chapterPrefix)) continue; + if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue; + await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {}); } - // Invalidate any combined "complete" files - const completeM4b = join(intermediateDir, `complete.m4b`); - const completeMp3 = join(intermediateDir, `complete.mp3`); - if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => { }); - if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => { }); - await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { }); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {}); return NextResponse.json({ success: true }); } catch (error) { console.error('Error deleting chapter:', error); - return NextResponse.json( - { error: 'Failed to delete chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 }); } } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 7ed21d1..ec09115 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -1,20 +1,34 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; -import { existsSync, createReadStream } from 'fs'; -import { basename, join } from 'path'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { randomUUID } from 'crypto'; -import { ensureAudiobooksV1Ready, isAudiobooksV1Ready, getAudiobooksRootDir } 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 { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { + audiobookPrefix, + deleteAudiobookObject, + deleteAudiobookPrefix, + getAudiobookObjectBuffer, + isMissingBlobError, + listAudiobookObjects, + putAudiobookObject, +} from '@/lib/server/audiobooks-blobstore'; +import { + decodeChapterFileName, + encodeChapterFileName, + encodeChapterTitleTag, + escapeFFMetadata, + ffprobeAudio, +} from '@/lib/server/audiobook'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; +import { getFFmpegPath, getFFprobePath } from '@/lib/server/ffmpeg-bin'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; @@ -27,13 +41,134 @@ interface ConversionRequest { settings?: AudiobookGenerationSettings; } +type ChapterObject = { + index: number; + title: string; + format: TTSAudiobookFormat; + fileName: string; +}; + +const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function isSafeId(value: string): boolean { + return SAFE_ID_REGEX.test(value); +} + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function chapterFileMimeType(format: TTSAudiobookFormat): string { + return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; +} + +function buildAtempoFilter(speed: number): string { + const clamped = Math.max(0.5, Math.min(speed, 3)); + if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; + const second = clamped / 2; + return `atempo=2.0,atempo=${second.toFixed(3)}`; +} + +function listChapterObjects(objectNames: string[]): ChapterObject[] { + const chapters = objectNames + .filter((name) => !name.startsWith('complete.')) + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + return { + index: decoded.index, + title: decoded.title, + format: decoded.format, + fileName, + } satisfies ChapterObject; + }) + .filter((value): value is ChapterObject => Boolean(value)) + .sort((a, b) => a.index - b.index); + + const deduped = new Map(); + for (const chapter of chapters) { + const existing = deduped.get(chapter.index); + if (!existing) { + deduped.set(chapter.index, chapter); + continue; + } + if (chapter.fileName > existing.fileName) { + deduped.set(chapter.index, chapter); + } + } + + return Array.from(deduped.values()).sort((a, b) => a.index - b.index); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const ffmpeg = spawn(getFFmpegPath(), args); + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffmpeg.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + ffmpeg.stderr.on('data', (data) => { + console.error(`ffmpeg stderr: ${data}`); + }); + + ffmpeg.on('close', (code) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + if (code === 0) { + resolve(); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + reject(err); + }); + }); +} + async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', [ - '-i', filePath, - '-show_entries', 'format=duration', - '-v', 'quiet', - '-of', 'csv=p=0' + const ffprobe = spawn(getFFprobePath(), [ + '-i', + filePath, + '-show_entries', + 'format=duration', + '-v', + 'quiet', + '-of', + 'csv=p=0', ]); let output = ''; @@ -44,7 +179,7 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise finished = true; try { ffprobe.kill('SIGKILL'); - } catch { } + } catch {} reject(new Error('ABORTED')); }; @@ -85,95 +220,25 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise }); } -async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', args); - - let finished = false; - - const onAbort = () => { - if (finished) return; - finished = true; - try { - ffmpeg.kill('SIGKILL'); - } catch { } - reject(new Error('ABORTED')); - }; - - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - } - - ffmpeg.stderr.on('data', (data) => { - console.error(`ffmpeg stderr: ${data}`); - }); - - ffmpeg.on('close', (code) => { - if (finished) return; - finished = true; - signal?.removeEventListener('abort', onAbort); - if (code === 0) { - resolve(); - } else { - reject(new Error(`FFmpeg process exited with code ${code}`)); - } - }); - - ffmpeg.on('error', (err) => { - if (finished) return; - finished = true; - signal?.removeEventListener('abort', onAbort); - reject(err); - }); - }); -} - -function buildAtempoFilter(speed: number): string { - const clamped = Math.max(0.5, Math.min(speed, 3)); - // atempo supports 0.5..2.0 per filter; chain for >2.0 - if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; - const second = clamped / 2; - return `atempo=2.0,atempo=${second.toFixed(3)}`; -} - -const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; - -function isSafeId(value: string): boolean { - return SAFE_ID_REGEX.test(value); -} - export async function POST(request: NextRequest) { + let workDir: string | null = null; try { - // Parse the request body + if (!isS3Configured()) return s3NotConfiguredResponse(); + const data: ConversionRequest = await request.json(); const requestedFormat = data.format || 'm4b'; - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; - const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); - // Generate or use existing book ID + const testNamespace = getOpenReaderTestNamespace(request.headers); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - // DB Check / Insert Audiobook await db .insert(audiobooks) .values({ @@ -183,57 +248,36 @@ export async function POST(request: NextRequest) { }) .onConflictDoNothing(); - const intermediateDir = join( - getAudiobooksRootDir({ - userId, - authEnabled: ctxOrRes.authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - // Create intermediate directory - await mkdir(intermediateDir, { recursive: true }); - - const existingChapters = await listStoredChapters(intermediateDir, request.signal); + const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace); + const objectNames = objects.map((item) => item.fileName); + const existingChapters = listChapterObjects(objectNames); const hasChapters = existingChapters.length > 0; - const metaPath = join(intermediateDir, 'audiobook.meta.json'); - const incomingSettings = data.settings; let existingSettings: AudiobookGenerationSettings | null = null; try { - existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings; - } catch { + existingSettings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; + } catch (error) { + if (!isMissingBlobError(error)) throw error; existingSettings = null; } - // Only enforce mismatch check if we already have generated chapters. - // If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial). - if (existingSettings && hasChapters) { - if (incomingSettings) { - const mismatch = - existingSettings.ttsProvider !== incomingSettings.ttsProvider || - existingSettings.ttsModel !== incomingSettings.ttsModel || - existingSettings.voice !== incomingSettings.voice || - existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || - existingSettings.postSpeed !== incomingSettings.postSpeed || - existingSettings.format !== incomingSettings.format; - if (mismatch) { - return NextResponse.json( - { error: 'Audiobook settings mismatch', settings: existingSettings }, - { status: 409 }, - ); - } + const incomingSettings = data.settings; + if (existingSettings && hasChapters && incomingSettings) { + const mismatch = + existingSettings.ttsProvider !== incomingSettings.ttsProvider || + existingSettings.ttsModel !== incomingSettings.ttsModel || + existingSettings.voice !== incomingSettings.voice || + existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || + existingSettings.postSpeed !== incomingSettings.postSpeed || + existingSettings.format !== incomingSettings.format; + if (mismatch) { + return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 }); } } - // Note: We deliberately do NOT write the meta file here yet. - // We wait until a chapter is successfully generated/saved below. - const existingFormats = new Set(existingChapters.map((c) => c.format)); + + const existingFormats = new Set(existingChapters.map((chapter) => chapter.format)); if (existingFormats.size > 1) { - return NextResponse.json( - { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }); } const format: TTSAudiobookFormat = @@ -244,8 +288,6 @@ export async function POST(request: NextRequest) { const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; - // Use provided chapter index or find the next available index robustly (handles gaps) - // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { const normalized = Number(data.chapterIndex); @@ -255,7 +297,6 @@ export async function POST(request: NextRequest) { chapterIndex = normalized; } else { const indices = existingChapters.map((c) => c.index); - // Find smallest non-negative integer not present let next = 0; for (const idx of indices) { if (idx === next) { @@ -267,76 +308,82 @@ export async function POST(request: NextRequest) { chapterIndex = next; } - // Write input file (MP3 from TTS) - const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); - const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`); + workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-')); + const inputPath = join(workDir, `${chapterIndex}-input.mp3`); + const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`); const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle); - // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); - // We intentionally do not delete the existing chapter file up-front. This avoids a long - // window where the chapter is "missing" while ffmpeg is running (which can lead to - // partial/stale "complete.*" downloads). We clean up duplicates and invalidate the - // combined output only after the new chapter is written successfully. - if (format === 'mp3') { - // For MP3, re-encode to ensure proper headers and consistent format - await runFFmpeg([ - '-y', // Overwrite output file without asking - '-i', inputPath, - ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), - '-c:a', 'libmp3lame', - '-b:a', '64k', - '-metadata', `title=${titleTag}`, - chapterOutputTempPath - ], request.signal); + await runFFmpeg( + [ + '-y', + '-i', + inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), + '-c:a', + 'libmp3lame', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + chapterOutputTempPath, + ], + request.signal, + ); } else { - // Convert MP3 to M4B container with proper encoding and metadata - await runFFmpeg([ - '-y', // Overwrite output file without asking - '-i', inputPath, - ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), - '-c:a', 'aac', - '-b:a', '64k', - '-metadata', `title=${titleTag}`, - '-f', 'mp4', - chapterOutputTempPath - ], request.signal); + await runFFmpeg( + [ + '-y', + '-i', + inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), + '-c:a', + 'aac', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + '-f', + 'mp4', + chapterOutputTempPath, + ], + request.signal, + ); } const probe = await ffprobeAudio(chapterOutputTempPath, request.signal); const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal)); - const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format)); - await unlink(finalChapterPath).catch(() => { }); - await rename(chapterOutputTempPath, finalChapterPath); + const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format); + const finalChapterBytes = await readFile(chapterOutputTempPath); + await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace); - // Remove any existing chapter files for this index (e.g., if the title changed and the - // filename changed) and invalidate the combined output now that the chapter is updated. const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; - const finalChapterName = basename(finalChapterPath); - const existingFiles = await readdir(intermediateDir).catch(() => []); - for (const file of existingFiles) { - if (!file.startsWith(chapterPrefix)) continue; - if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; - if (file === finalChapterName) continue; - await unlink(join(intermediateDir, file)).catch(() => { }); + for (const fileName of objectNames) { + if (!fileName.startsWith(chapterPrefix)) continue; + if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue; + if (fileName === finalChapterName) continue; + await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {}); } - await unlink(join(intermediateDir, 'complete.mp3')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { }); - // Ensure meta exists after first successful chapter. + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {}); + if (!existingSettings && incomingSettings) { - await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => { }); + await putAudiobookObject( + bookId, + storageUserId, + 'audiobook.meta.json', + Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'), + 'application/json; charset=utf-8', + testNamespace, + ); } - // Clean up input file - await unlink(inputPath).catch(console.error); - - // Insert Chapter Record (Denormalized) await db .insert(audiobookChapters) .values({ @@ -360,26 +407,24 @@ export async function POST(request: NextRequest) { duration, status: 'completed' as const, bookId, - format + format, }); - } catch (error) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { - return NextResponse.json( - { error: 'cancelled' }, - { status: 499 } - ); + return NextResponse.json({ error: 'cancelled' }, { status: 499 }); } console.error('Error processing audio chapter:', error); - return NextResponse.json( - { error: 'Failed to process audio chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); + } finally { + if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } } export async function GET(request: NextRequest) { + let workDir: string | null = null; try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null; if (!bookId) { @@ -389,25 +434,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Check if audiobook exists for user OR is unclaimed (similar to documents) const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -416,193 +451,163 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - if (!existsSync(intermediateDir)) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - const stored = await listStoredChapters(intermediateDir, request.signal); - const chapters = stored.map((chapter) => ({ - title: chapter.title, - duration: chapter.durationSec ?? 0, - index: chapter.index, - format: chapter.format, - filePath: chapter.filePath, - })); - + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const objectNames = objects.map((item) => item.fileName); + const chapters = listChapterObjects(objectNames); if (chapters.length === 0) { return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); } const chapterFormats = new Set(chapters.map((chapter) => chapter.format)); if (chapterFormats.size > 1) { - return NextResponse.json( - { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }); } - // Sort chapters by index - chapters.sort((a, b) => a.index - b.index); - const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b'; - const outputPath = join(intermediateDir, `complete.${format}`); - const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`); - const metadataPath = join(intermediateDir, 'metadata.txt'); - const listPath = join(intermediateDir, 'list.txt'); + const format: TTSAudiobookFormat = requestedFormat ?? chapters[0].format; + const completeName = `complete.${format}`; + const manifestName = `${completeName}.manifest.json`; + const signature = chapters.map((chapter) => ({ index: chapter.index, fileName: chapter.fileName })); - const signature = chapters.map((chapter) => ({ - index: chapter.index, - fileName: basename(chapter.filePath), - })); - - if (existsSync(outputPath)) { - let cached: typeof signature | null = null; + if (objectNames.includes(completeName) && objectNames.includes(manifestName)) { try { - cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature; - } catch { - cached = null; - } - - if (cached && JSON.stringify(cached) === JSON.stringify(signature)) { - return streamFile(outputPath, format); - } - - await unlink(outputPath).catch(() => { }); - await unlink(manifestPath).catch(() => { }); - } - - // Ensure we have chapter durations for chapter markers / ordering. - for (const chapter of chapters) { - if (chapter.duration && chapter.duration > 0) continue; - try { - const probe = await ffprobeAudio(chapter.filePath, request.signal); - if (probe.durationSec && probe.durationSec > 0) { - chapter.duration = probe.durationSec; - continue; + const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, manifestName, testNamespace)).toString('utf8')); + if (JSON.stringify(manifest) === JSON.stringify(signature)) { + const cached = await getAudiobookObjectBuffer(bookId, existingBook.userId, completeName, testNamespace); + return new NextResponse(streamBuffer(cached), { + headers: { + 'Content-Type': chapterFileMimeType(format), + 'Content-Disposition': `attachment; filename="audiobook.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); } - } catch { } - - try { - chapter.duration = await getAudioDuration(chapter.filePath, request.signal); } catch { - chapter.duration = 0; + // Force regeneration below. } + + await deleteAudiobookObject(bookId, existingBook.userId, completeName, testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, existingBook.userId, manifestName, testNamespace).catch(() => {}); + } + + const chapterRows = await db + .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) + .from(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + const durationByIndex = new Map(); + for (const row of chapterRows) { + durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); + } + + workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-combine-')); + const metadataPath = join(workDir, 'metadata.txt'); + const listPath = join(workDir, 'list.txt'); + const outputPath = join(workDir, completeName); + + const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = []; + for (const chapter of chapters) { + const localPath = join(workDir, chapter.fileName); + const bytes = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace); + await writeFile(localPath, bytes); + + let duration = durationByIndex.get(chapter.index) ?? 0; + if (!duration || duration <= 0) { + try { + const probe = await ffprobeAudio(localPath, request.signal); + if (probe.durationSec && probe.durationSec > 0) { + duration = probe.durationSec; + } else { + duration = await getAudioDuration(localPath, request.signal); + } + } catch { + duration = 0; + } + } + + localChapters.push({ + index: chapter.index, + title: chapter.title, + localPath, + duration, + }); } - // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; - - for (const chapter of chapters) { + for (const chapter of localChapters) { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); - metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`); } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); - - // Create list file for concat await writeFile( listPath, - chapters.map(c => `file '${c.filePath}'`).join('\n') + localChapters + .map((chapter) => `file '${chapter.localPath.replace(/'/g, "'\\''")}'`) + .join('\n'), ); if (format === 'mp3') { - // For MP3, re-encode to properly rebuild headers and duration metadata - // Using libmp3lame to ensure proper MP3 structure - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-c:a', 'libmp3lame', - '-b:a', '64k', - outputPath - ], request.signal); + await runFFmpeg(['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], request.signal); } else { - // Combine all files into a single M4B with chapter metadata - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-i', metadataPath, - '-map_metadata', '1', - '-c:a', 'aac', - '-b:a', '64k', - '-f', 'mp4', - outputPath - ], request.signal); - } - - // Clean up temporary files (but keep the chapters and complete file) - await Promise.all([ - unlink(metadataPath).catch(console.error), - unlink(listPath).catch(console.error) - ]); - - await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => { }); - - // Stream the file back to the client - return streamFile(outputPath, format); - - } catch (error) { - if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { - return NextResponse.json( - { error: 'cancelled' }, - { status: 499 } + await runFFmpeg( + [ + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, + '-i', + metadataPath, + '-map_metadata', + '1', + '-c:a', + 'aac', + '-b:a', + '64k', + '-f', + 'mp4', + outputPath, + ], + request.signal, ); } - console.error('Error creating M4B:', error); - return NextResponse.json( - { error: 'Failed to create M4B file' }, - { status: 500 } + + const outputBytes = await readFile(outputPath); + await putAudiobookObject(bookId, existingBook.userId, completeName, outputBytes, chapterFileMimeType(format), testNamespace); + await putAudiobookObject( + bookId, + existingBook.userId, + manifestName, + Buffer.from(JSON.stringify(signature, null, 2), 'utf8'), + 'application/json; charset=utf-8', + testNamespace, ); + + return new NextResponse(streamBuffer(outputBytes), { + headers: { + 'Content-Type': chapterFileMimeType(format), + 'Content-Disposition': `attachment; filename="audiobook.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); + } catch (error) { + if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { + return NextResponse.json({ error: 'cancelled' }, { status: 499 }); + } + console.error('Error creating full audiobook:', error); + return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 }); + } finally { + if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } } -// Helper function to stream file -function streamFile(filePath: string, format: string) { - const stream = createReadStream(filePath); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, - cancel() { - stream.destroy(); - } - }); - - const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; - - return new NextResponse(readableWebStream, { - headers: { - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="audiobook.${format}"`, - 'Cache-Control': 'no-cache', - }, - }); -} export async function DELETE(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); @@ -611,23 +616,11 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); - await ensureDbIndexed(); - - // Delete from DB - with composite PK, we delete by both id and userId const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -637,36 +630,16 @@ export async function DELETE(request: NextRequest) { 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, storageUserId))); await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - const intermediateDir = join( - getAudiobooksRootDir({ - userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - // If directory doesn't exist, consider it already reset - if (!existsSync(intermediateDir)) { - return NextResponse.json({ success: true, existed: false }); - } - - // Recursively delete the entire audiobook directory - await rm(intermediateDir, { recursive: true, force: true }); - - return NextResponse.json({ success: true, existed: true }); + const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0); + return NextResponse.json({ success: true, existed: deleted > 0 }); } catch (error) { console.error('Error resetting audiobook:', error); - return NextResponse.json( - { error: 'Failed to reset audiobook' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 }); } } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 1f502ca..3530704 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,18 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { getAudiobooksRootDir, ensureAudiobooksV1Ready, 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 } from '@/lib/server/auth'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; -import { audiobooks } from '@/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { audiobooks, audiobookChapters } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } from '@/lib/server/audiobooks-blobstore'; +import { decodeChapterFileName } from '@/lib/server/audiobook'; +import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobook-prune'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookChaptersNotOnDisk, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; @@ -22,21 +19,55 @@ function isSafeId(value: string): boolean { return SAFE_ID_REGEX.test(value); } +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +type ChapterObject = { + index: number; + title: string; + format: TTSAudiobookFormat; + fileName: string; +}; + +function listChapterObjects(fileNames: string[]): ChapterObject[] { + const chapters = fileNames + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + return { + index: decoded.index, + title: decoded.title, + format: decoded.format, + fileName, + } satisfies ChapterObject; + }) + .filter((value): value is ChapterObject => Boolean(value)) + .sort((a, b) => a.index - b.index); + + const deduped = new Map(); + for (const chapter of chapters) { + const current = deduped.get(chapter.index); + if (!current || chapter.fileName > current.fileName) { + deduped.set(chapter.index, chapter); + } + } + + return Array.from(deduped.values()).sort((a, b) => a.index - b.index); +} + export async function GET(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); if (!bookId || !isSafeId(bookId)) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; @@ -46,15 +77,12 @@ export async function GET(request: NextRequest) { const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Check if audiobook exists for user OR is unclaimed (similar to documents) const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { - // Book doesn't exist for this user or unclaimed - return empty state return NextResponse.json({ chapters: [], exists: false, @@ -64,49 +92,56 @@ export async function GET(request: NextRequest) { }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const objectNames = objects.map((object) => object.fileName); + const chapterObjects = listChapterObjects(objectNames); - if (!existsSync(intermediateDir)) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); - return NextResponse.json({ - chapters: [], - exists: false, - hasComplete: false, - bookId: null, - settings: null, - }); - } - - const stored = await listStoredChapters(intermediateDir, request.signal); await pruneAudiobookChaptersNotOnDisk( bookId, existingBook.userId, - stored.map((c) => c.index), + chapterObjects.map((chapter) => chapter.index), ); - const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({ + + const chapterRows = await db + .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) + .from(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + const durationByIndex = new Map(); + for (const row of chapterRows) { + durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); + } + + const chapters: TTSAudiobookChapter[] = chapterObjects.map((chapter) => ({ index: chapter.index, title: chapter.title, - duration: chapter.durationSec, + duration: durationByIndex.get(chapter.index), status: 'completed', bookId, - format: chapter.format as TTSAudiobookFormat, + format: chapter.format, })); let settings: AudiobookGenerationSettings | null = null; try { - settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings; - } catch { + settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; + } catch (error) { + if (!isMissingBlobError(error)) throw error; settings = null; } - const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b')); + const hasComplete = objectNames.includes('complete.mp3') || objectNames.includes('complete.m4b'); + const exists = chapters.length > 0 || hasComplete || settings !== null; + + if (!exists) { + await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBook.userId))); + return NextResponse.json({ + chapters: [], + exists: false, + hasComplete: false, + bookId: null, + settings: null, + }); + } return NextResponse.json({ chapters, @@ -115,12 +150,8 @@ export async function GET(request: NextRequest) { bookId, settings, }); - } catch (error) { console.error('Error fetching chapters:', error); - return NextResponse.json( - { error: 'Failed to fetch chapters' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 }); } } diff --git a/src/app/api/migrations/v1/route.ts b/src/app/api/migrations/v1/route.ts deleted file mode 100644 index cbee2db..0000000 --- a/src/app/api/migrations/v1/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { existsSync } from 'fs'; -import { mkdir, readdir, rename, rm } from 'fs/promises'; -import { join } from 'path'; -import { - AUDIOBOOKS_V1_DIR, - ensureAudiobooksV1Ready, - ensureDocumentsV1Ready, - isAudiobooksV1Ready, - isDocumentsV1Ready, -} from '@/lib/server/docstore'; -import { auth } from '@/lib/server/auth'; - -type Mapping = { oldId: string; id: string }; - -function isSafeId(value: string): boolean { - return /^[a-zA-Z0-9._-]{1,128}$/.test(value); -} - -async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { - let moved = 0; - let skipped = 0; - - let entries: Array = []; - try { - entries = await readdir(sourceDir, { withFileTypes: true }); - } catch { - return { moved, skipped }; - } - - for (const entry of entries) { - const sourcePath = join(sourceDir, entry.name); - const targetPath = join(targetDir, entry.name); - - if (entry.isDirectory()) { - await mkdir(targetPath, { recursive: true }); - const nested = await mergeDirectoryContents(sourcePath, targetPath); - moved += nested.moved; - skipped += nested.skipped; - - try { - const remaining = await readdir(sourcePath); - if (remaining.length === 0) { - await rm(sourcePath); - } - } catch { } - continue; - } - - if (!entry.isFile()) continue; - - if (existsSync(targetPath)) { - skipped++; - continue; - } - - try { - await rename(sourcePath, targetPath); - moved++; - } catch { - skipped++; - } - } - - return { moved, skipped }; -} - -async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number; merged: number; skipped: number }> { - let renamed = 0; - let merged = 0; - let skipped = 0; - - for (const mapping of mappings) { - if (mapping.oldId === mapping.id) continue; - const sourceDir = join(AUDIOBOOKS_V1_DIR, `${mapping.oldId}-audiobook`); - if (!existsSync(sourceDir)) continue; - - const targetDir = join(AUDIOBOOKS_V1_DIR, `${mapping.id}-audiobook`); - if (!existsSync(targetDir)) { - try { - await rename(sourceDir, targetDir); - renamed++; - continue; - } catch { - // Fall through to merge. - } - } - - await mkdir(targetDir, { recursive: true }); - const res = await mergeDirectoryContents(sourceDir, targetDir); - if (res.moved > 0) merged++; - skipped += res.skipped; - - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir); - } - } catch { } - } - - return { renamed, merged, skipped }; -} - -export async function POST(request: NextRequest) { - try { - // Auth check - require session - const session = await auth?.api.getSession({ headers: request.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null; - const mappings = (raw?.mappings ?? []).filter( - (m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'), - ); - - for (const mapping of mappings) { - if (!isSafeId(mapping.oldId) || !isSafeId(mapping.id)) { - return NextResponse.json({ error: 'Invalid document id mapping' }, { status: 400 }); - } - } - - const documentsMigrated = await ensureDocumentsV1Ready(); - const audiobooksMigrated = await ensureAudiobooksV1Ready(); - const rekey = await rekeyAudiobooksV1(mappings); - - const documentsReady = await isDocumentsV1Ready(); - const audiobooksReady = await isAudiobooksV1Ready(); - - return NextResponse.json({ - success: true, - documentsReady, - audiobooksReady, - documentsMigrated, - audiobooksMigrated, - rekey, - }); - } catch (error) { - console.error('Error running v1 migrations:', error); - return NextResponse.json({ error: 'Failed to run v1 migrations' }, { status: 500 }); - } -} - diff --git a/src/app/api/migrations/v2/route.ts b/src/app/api/migrations/v2/route.ts deleted file mode 100644 index d289f03..0000000 --- a/src/app/api/migrations/v2/route.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { createHash } from 'crypto'; -import { existsSync } from 'fs'; -import { readdir, readFile, stat, unlink } from 'fs/promises'; -import path from 'path'; -import { and, eq } from 'drizzle-orm'; -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { auth } from '@/lib/server/auth'; -import { DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; -import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents-blobstore'; -import { toDocumentTypeFromName } from '@/lib/server/documents-utils'; -import { contentTypeForName } from '@/lib/server/library'; -import { isS3Configured } from '@/lib/server/s3'; -import type { DocumentType } from '@/types/documents'; -import { - applyOpenReaderTestNamespacePath, - getOpenReaderTestNamespace, - getUnclaimedUserIdForNamespace, -} from '@/lib/server/test-namespace'; - -export const dynamic = 'force-dynamic'; - -type V2Body = { - deleteLocal?: boolean; - dryRun?: boolean; -}; - -type LegacyDocumentCandidate = { - id: string; - name: string; - type: string; - size: number; - lastModified: number; -}; - -function isPreconditionFailed(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; - const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; - return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; -} - -function extractIdFromFileName(fileName: string): string | null { - const match = /^([a-f0-9]{64})__/i.exec(fileName); - if (!match) return null; - const id = match[1].toLowerCase(); - return isValidDocumentId(id) ? id : null; -} - -function decodeNameFromFileName(fileName: string, id: string): string { - const prefix = `${id}__`; - if (!fileName.startsWith(prefix)) return `${id}.bin`; - const encoded = fileName.slice(prefix.length); - try { - return decodeURIComponent(encoded); - } catch { - return `${id}.bin`; - } -} - -function sniffBinaryDocumentType(bytes: Buffer): Exclude | null { - // PDF signature: "%PDF-" - if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') { - return 'pdf'; - } - - // ZIP signatures: PK.. - const isZip = - bytes.length >= 4 && - bytes[0] === 0x50 && - bytes[1] === 0x4b && - (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) && - (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08); - if (!isZip) return null; - - // EPUB/DOCX markers usually appear in ZIP local headers near the start. - const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1'); - if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) { - return 'epub'; - } - if (probe.includes('[Content_Types].xml') && probe.includes('word/')) { - return 'docx'; - } - - return null; -} - -function normalizeNameForType(name: string, id: string, type: DocumentType): string { - if (type === 'html') return name; - const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx'; - if (name.toLowerCase().endsWith(expectedExt)) return name; - const base = name.replace(/\.bin$/i, ''); - return `${base || id}${expectedExt}`; -} - -function contentTypeForDocument(type: DocumentType, name: string): string { - if (type === 'pdf') return 'application/pdf'; - if (type === 'epub') return 'application/epub+zip'; - if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - return contentTypeForName(name); -} - -async function migrateDocumentRowsToBlobHandle(dryRun: boolean): Promise { - const rows = (await db.select().from(documents)) as Array<{ - id: string; - userId: string; - filePath: string; - }>; - - let updated = 0; - for (const row of rows) { - if (!row.id || !row.userId || row.filePath === row.id) continue; - updated++; - if (dryRun) continue; - await db - .update(documents) - .set({ filePath: row.id }) - .where(and(eq(documents.id, row.id), eq(documents.userId, row.userId))); - } - return updated; -} - -async function seedMissingDocumentRows( - userId: string, - candidates: LegacyDocumentCandidate[], - dryRun: boolean, -): Promise { - if (candidates.length === 0) return 0; - - const existingRows = (await db.select().from(documents).where(eq(documents.userId, userId))) as Array<{ - id: string; - }>; - const existingIds = new Set(existingRows.map((row) => row.id)); - - const seen = new Set(); - const toInsert: LegacyDocumentCandidate[] = []; - for (const candidate of candidates) { - if (seen.has(candidate.id)) continue; - seen.add(candidate.id); - if (existingIds.has(candidate.id)) continue; - toInsert.push(candidate); - } - - if (toInsert.length === 0) return 0; - if (dryRun) return toInsert.length; - - await db.insert(documents).values( - toInsert.map((candidate) => ({ - id: candidate.id, - userId, - name: candidate.name, - type: candidate.type, - size: candidate.size, - lastModified: candidate.lastModified, - filePath: candidate.id, - })), - ).onConflictDoNothing(); - - return toInsert.length; -} - -export async function POST(request: NextRequest) { - try { - const session = await auth?.api.getSession({ headers: request.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (!isS3Configured()) { - return NextResponse.json( - { error: 'S3 is not configured. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.' }, - { status: 409 }, - ); - } - - const raw = (await request.json().catch(() => ({}))) as V2Body; - const dryRun = raw.dryRun === true; - const deleteLocal = raw.deleteLocal === true; - const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const docsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); - - if (!existsSync(docsDir)) { - const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); - return NextResponse.json({ - success: true, - dryRun, - deleteLocal, - docsDir, - filesScanned: 0, - uploaded: 0, - alreadyPresent: 0, - skippedInvalid: 0, - deletedLocal: 0, - dbRowsUpdated: rowsUpdated, - dbRowsSeeded: 0, - }); - } - - const entries = await readdir(docsDir, { withFileTypes: true }); - const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name); - - let uploaded = 0; - let alreadyPresent = 0; - let skippedInvalid = 0; - let deletedLocal = 0; - const candidates: LegacyDocumentCandidate[] = []; - - for (const fileName of files) { - const fullPath = path.join(docsDir, fileName); - const bytes = await readFile(fullPath); - const fileStats = await stat(fullPath); - - const extractedId = extractIdFromFileName(fileName); - const id = extractedId ?? createHash('sha256').update(bytes).digest('hex'); - if (!isValidDocumentId(id)) { - skippedInvalid++; - continue; - } - - const inferredName = decodeNameFromFileName(fileName, id); - const inferredType = toDocumentTypeFromName(inferredName); - const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; - const normalizedName = normalizeNameForType(inferredName, id, type); - const contentType = contentTypeForDocument(type, normalizedName); - const lastModified = Number.isFinite(fileStats.mtimeMs) ? Math.floor(fileStats.mtimeMs) : Date.now(); - - candidates.push({ - id, - name: normalizedName, - type, - size: bytes.length, - lastModified, - }); - - if (!dryRun) { - try { - await putDocumentBlob(id, bytes, contentType, testNamespace); - uploaded++; - } catch (error) { - if (isPreconditionFailed(error)) { - alreadyPresent++; - } else { - throw error; - } - } - } - - if (deleteLocal && !dryRun) { - await unlink(fullPath).catch(() => {}); - deletedLocal++; - } - } - - const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); - const rowsSeeded = await seedMissingDocumentRows(unclaimedUserId, candidates, dryRun); - - return NextResponse.json({ - success: true, - dryRun, - deleteLocal, - docsDir, - filesScanned: files.length, - uploaded, - alreadyPresent, - skippedInvalid, - deletedLocal, - dbRowsUpdated: rowsUpdated, - dbRowsSeeded: rowsSeeded, - }); - } catch (error) { - console.error('Error running v2 migrations:', error); - return NextResponse.json({ error: 'Failed to run v2 migrations' }, { status: 500 }); - } -} diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 592cbfa..c537cd6 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -1,21 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; -import { ensureDbIndexed, getUnclaimedCounts } from '@/lib/server/db-indexing'; -import { isDocumentsV1Ready } from '@/lib/server/docstore'; import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { count, ne } from 'drizzle-orm'; +import { audiobooks, documents } from '@/db/schema'; +import { count, eq, ne } from 'drizzle-orm'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; async function checkClaimMigrationReadiness(): Promise { - const documentsV1Ready = await isDocumentsV1Ready(); - if (!documentsV1Ready) { - return NextResponse.json( - { error: 'Document migration is not ready. Run startup migrations first.' }, - { status: 409 }, - ); - } - const [legacyRows] = await db .select({ count: count() }) .from(documents) @@ -31,6 +22,22 @@ async function checkClaimMigrationReadiness(): Promise { return null; } +async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> { + const [docCount] = await db + .select({ count: count() }) + .from(documents) + .where(eq(documents.userId, unclaimedUserId)); + const [bookCount] = await db + .select({ count: count() }) + .from(audiobooks) + .where(eq(audiobooks.userId, unclaimedUserId)); + + return { + documents: Number(docCount?.count ?? 0), + audiobooks: Number(bookCount?.count ?? 0), + }; +} + export async function GET(req: NextRequest) { try { const session = await auth?.api.getSession({ headers: req.headers }); @@ -41,8 +48,9 @@ export async function GET(req: NextRequest) { const readiness = await checkClaimMigrationReadiness(); if (readiness) return readiness; - await ensureDbIndexed(); - const counts = await getUnclaimedCounts(); + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const counts = await getClaimableCounts(unclaimedUserId); return NextResponse.json({ success: true, ...counts }); } catch (error) { console.error('Error checking claimable data:', error); @@ -60,10 +68,11 @@ export async function POST(req: NextRequest) { const readiness = await checkClaimMigrationReadiness(); if (readiness) return readiness; + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const userId = session.user.id; - await ensureDbIndexed(); - const result = await claimAnonymousData(userId); + const result = await claimAnonymousData(userId, unclaimedUserId, testNamespace); return NextResponse.json({ success: true, diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index bcec3db..44364f3 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -7,6 +7,7 @@ import { spawn } from 'child_process'; import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts'; import { preprocessSentenceForAudio } from '@/lib/nlp'; import { auth } from '@/lib/server/auth'; +import { getFFmpegPath } from '@/lib/server/ffmpeg-bin'; export const runtime = 'nodejs'; @@ -353,7 +354,7 @@ async function alignAudioWithText( await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); await new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', [ + const ffmpeg = spawn(getFFmpegPath(), [ '-y', '-i', inputPath, diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 7744c67..51d5d0e 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -19,7 +19,7 @@ import { resolveDocumentId } from '@/lib/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; +const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false'; export default function EPUBPage() { const { id } = useParams(); @@ -158,7 +158,7 @@ export default function EPUBPage() { onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} onOpenSettings={() => setIsSettingsOpen(true)} onOpenAudiobook={() => setIsAudiobookModalOpen(true)} - isDev={isDev} + showAudiobookExport={canExportAudiobook} minZoom={0} maxZoom={100} /> @@ -177,7 +177,7 @@ export default function EPUBPage() { )} - {isDev && ( + {canExportAudiobook && ( setIsSettingsOpen(true)} onOpenAudiobook={() => setIsAudiobookModalOpen(true)} - isDev={isDev} + showAudiobookExport={canExportAudiobook} minZoom={50} maxZoom={300} /> @@ -171,7 +171,7 @@ export default function PDFViewerPage() { )} - {isDev && ( + {canExportAudiobook && ( void; onOpenSettings: () => void; onOpenAudiobook?: () => void; - isDev?: boolean; + showAudiobookExport?: boolean; minZoom?: number; maxZoom?: number; } @@ -23,7 +23,7 @@ export function DocumentHeaderMenu({ onZoomDecrease, onOpenSettings, onOpenAudiobook, - isDev, + showAudiobookExport, minZoom = 0, maxZoom = 100 }: DocumentHeaderMenuProps) { @@ -38,7 +38,7 @@ export function DocumentHeaderMenu({ min={minZoom} max={maxZoom} /> - {isDev && onOpenAudiobook && ( + {showAudiobookExport && onOpenAudiobook && ( } @@ -355,7 +357,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { updateConfigKey('pdfWordHighlightEnabled', e.target.checked) } @@ -366,7 +368,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {

- Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'} + Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}

@@ -392,7 +394,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { updateConfigKey('epubWordHighlightEnabled', e.target.checked) } @@ -403,7 +405,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {

- Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'} + Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}

diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 9773e20..74737b9 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, getDocumentIdMappings, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; +import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; @@ -99,58 +99,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const run = async () => { try { await migrateLegacyDexieDocumentIdsToSha(); - const mappings = await getDocumentIdMappings(); - - // Run server-side v1 migrations proactively, since the client may now - // reference SHA-based IDs immediately after the Dexie migration. - const response = await fetch('/api/migrations/v1', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mappings }), - }).catch(() => null); - - if (response?.ok) { - const data = await response.json(); - const v2ApplyResponse = await fetch('/api/migrations/v2', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dryRun: false, deleteLocal: false }), - }).catch(() => null); - const v2ApplyData = v2ApplyResponse?.ok ? await v2ApplyResponse.json().catch(() => null) : null; - - const didMigrateV1 = - data.documentsMigrated || - data.audiobooksMigrated || - (data.rekey?.renamed ?? 0) > 0 || - (data.rekey?.merged ?? 0) > 0; - - if (v2ApplyData) { - const uploaded = Number(v2ApplyData.uploaded ?? 0); - const alreadyPresent = Number(v2ApplyData.alreadyPresent ?? 0); - const dbRowsUpdated = Number(v2ApplyData.dbRowsUpdated ?? 0); - const dbRowsSeeded = Number(v2ApplyData.dbRowsSeeded ?? 0); - const deletedLocal = Number(v2ApplyData.deletedLocal ?? 0); - const dbRowsMigrated = dbRowsUpdated + dbRowsSeeded; - const didMigrateV2 = uploaded > 0 || dbRowsMigrated > 0 || deletedLocal > 0; - - if (didMigrateV2) { - toast.success( - `Legacy document migration complete: ${uploaded} uploaded, ${alreadyPresent} already in S3, ${dbRowsMigrated} DB row(s) migrated.`, - { duration: 6000, icon: '📦' }, - ); - window.dispatchEvent(new CustomEvent('openreader:documentsChanged', { - detail: { reason: 'migration-v2-complete' }, - })); - } - } - - if (didMigrateV1) { - toast.success('Library migration complete', { - duration: 5000, - icon: '📦', - }); - } - } } catch (error) { console.warn('Startup migrations failed:', error); } diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index 86993b5..9c1300a 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import path from 'path'; import { readdir } from 'fs/promises'; +import { getFFprobePath } from '@/lib/server/ffmpeg-bin'; export type StoredChapter = { index: number; @@ -78,7 +79,7 @@ type ProbeResult = { export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', [ + const ffprobe = spawn(getFFprobePath(), [ '-v', 'quiet', '-print_format', diff --git a/src/lib/server/audiobooks-blobstore.ts b/src/lib/server/audiobooks-blobstore.ts new file mode 100644 index 0000000..127e219 --- /dev/null +++ b/src/lib/server/audiobooks-blobstore.ts @@ -0,0 +1,251 @@ +import { + DeleteObjectCommand, + DeleteObjectsCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { getS3Client, getS3Config } from '@/lib/server/s3'; + +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_BOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_USER_ID_REGEX = /^[a-zA-Z0-9._:-]{1,256}$/; + +export type AudiobookBlobObject = { + key: string; + fileName: string; + size: number; + lastModified: number; + eTag: string | null; +}; + +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null; + return namespace; +} + +function assertSafeBookId(bookId: string): void { + if (!SAFE_BOOK_ID_REGEX.test(bookId)) { + throw new Error(`Invalid audiobook id: ${bookId}`); + } +} + +function assertSafeUserId(userId: string): void { + if (!SAFE_USER_ID_REGEX.test(userId)) { + throw new Error(`Invalid user id for audiobook storage scope: ${userId}`); + } +} + +function assertSafeFileName(fileName: string): void { + if (!fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\')) { + throw new Error(`Invalid audiobook file name: ${fileName}`); + } +} + +function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { + return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + } + return Buffer.concat(chunks); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + + if (isNodeReadableStream(body)) { + return streamToBuffer(body); + } + + throw new Error('Unsupported S3 response body type'); +} + +export function isPreconditionFailed(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; + return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; +} + +export function isMissingBlobError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } }; + if (maybe.$metadata?.httpStatusCode === 404) return true; + if (maybe.name === 'NotFound' || maybe.name === 'NoSuchKey') return true; + if (maybe.Code === 'NotFound' || maybe.Code === 'NoSuchKey') return true; + return false; +} + +export function audiobookPrefix(bookId: string, userId: string, namespace: string | null): string { + assertSafeBookId(bookId); + assertSafeUserId(userId); + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(userId)}/${bookId}-audiobook/`; +} + +export function audiobookKey(bookId: string, userId: string, fileName: string, namespace: string | null): string { + assertSafeFileName(fileName); + return `${audiobookPrefix(bookId, userId, namespace)}${fileName}`; +} + +export async function listAudiobookObjects(bookId: string, userId: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const prefix = audiobookPrefix(bookId, userId, namespace); + let continuationToken: string | undefined; + const objects: AudiobookBlobObject[] = []; + + do { + const listRes = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + + for (const entry of listRes.Contents ?? []) { + const key = entry.Key; + if (!key || !key.startsWith(prefix)) continue; + const fileName = key.slice(prefix.length); + if (!fileName || fileName.includes('/')) continue; + objects.push({ + key, + fileName, + size: Number(entry.Size ?? 0), + lastModified: entry.LastModified?.getTime() ?? 0, + eTag: entry.ETag ?? null, + }); + } + + continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; + } while (continuationToken); + + return objects; +} + +export async function headAudiobookObject( + bookId: string, + userId: string, + fileName: string, + namespace: string | null, +): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key })); + return { + contentLength: Number(res.ContentLength ?? 0), + contentType: res.ContentType ?? null, + eTag: res.ETag ?? null, + }; +} + +export async function getAudiobookObjectBuffer(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key })); + return bodyToBuffer(res.Body); +} + +export async function getAudiobookObjectStream(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key })); + return res.Body; +} + +export async function putAudiobookObject( + bookId: string, + userId: string, + fileName: string, + body: Buffer, + contentType: string, + namespace: string | null, + options?: { ifNoneMatch?: boolean }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: contentType, + ...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}), + }), + ); +} + +export async function deleteAudiobookObject(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); +} + +export async function deleteAudiobookPrefix(prefix: string): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const cleanedPrefix = prefix.replace(/^\/+/, ''); + let deleted = 0; + let continuationToken: string | undefined; + + do { + const listRes = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: cleanedPrefix, + ContinuationToken: continuationToken, + }), + ); + + const keys = (listRes.Contents ?? []) + .map((item) => item.Key) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + + if (keys.length > 0) { + const deleteRes = await client.send( + new DeleteObjectsCommand({ + Bucket: cfg.bucket, + Delete: { + Objects: keys.map((Key) => ({ Key })), + Quiet: true, + }, + }), + ); + deleted += deleteRes.Deleted?.length ?? 0; + } + + continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; + } while (continuationToken); + + return deleted; +} diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index f9b3dbd..32624f4 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,85 +1,85 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; -import fs from 'fs/promises'; +import { eq } from 'drizzle-orm'; +import { UNCLAIMED_USER_ID } from './docstore'; import { - UNCLAIMED_USER_ID, - getUserAudiobookDir, - moveAudiobookToUser, - listUserAudiobookIds, -} from './docstore'; + deleteAudiobookObject, + getAudiobookObjectBuffer, + listAudiobookObjects, + putAudiobookObject, +} from './audiobooks-blobstore'; +import { isS3Configured } from './s3'; import { isAuthEnabled } from '@/lib/server/auth-config'; -export async function claimAnonymousData(userId: string) { - if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; +type AudiobookRow = { + id: string; + userId: string; + title: string; + author: string | null; + description: string | null; + coverPath: string | null; + duration: number | null; + createdAt: unknown; +}; - // Get list of unclaimed audiobook IDs before updating DB - const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID); +type AudiobookChapterRow = { + id: string; + bookId: string; + userId: string; + chapterIndex: number; + title: string; + duration: number | null; + filePath: string; + format: string; +}; - // Update Documents - documents use shared storage, only DB update needed - const docResult = await db.update(documents) - .set({ userId }) - .where(eq(documents.userId, UNCLAIMED_USER_ID)) - .returning({ id: documents.id }); +function contentTypeForAudiobookObject(fileName: string): string { + if (fileName.endsWith('.mp3')) return 'audio/mpeg'; + if (fileName.endsWith('.m4b')) return 'audio/mp4'; + if (fileName.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} - // For audiobooks, we need to: - // 1. Move the physical folders from unclaimed to user's folder - // 2. Update the DB records +async function moveAudiobookBlobScope( + bookId: string, + fromUserId: string, + toUserId: string, + namespace: string | null, +): Promise { + if (fromUserId === toUserId) return; - let audiobooksClaimedCount = 0; - const userDir = getUserAudiobookDir(userId); - await fs.mkdir(userDir, { recursive: true }); + const objects = await listAudiobookObjects(bookId, fromUserId, namespace); + if (objects.length === 0) return; - 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); - } + for (const object of objects) { + const bytes = await getAudiobookObjectBuffer(bookId, fromUserId, object.fileName, namespace); + await putAudiobookObject( + bookId, + toUserId, + object.fileName, + bytes, + contentTypeForAudiobookObject(object.fileName), + namespace, + ); } + for (const object of objects) { + await deleteAudiobookObject(bookId, fromUserId, object.fileName, namespace).catch(() => {}); + } +} + +export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { + if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; + + const [documentsClaimed, audiobooksClaimed] = await Promise.all([ + transferUserDocuments(unclaimedUserId, userId), + transferUserAudiobooks(unclaimedUserId, userId, namespace), + ]); + return { - documents: docResult.length, - audiobooks: audiobooksClaimedCount + documents: documentsClaimed, + audiobooks: audiobooksClaimed, }; } @@ -120,60 +120,44 @@ export async function transferUserDocuments( * Used when an anonymous user creates a real account. * @returns number of audiobooks transferred */ -export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise { +export async function transferUserAudiobooks( + fromUserId: string, + toUserId: string, + namespace: string | null = null, +): Promise { if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; - const bookIds = await listUserAudiobookIds(fromUserId); - let transferred = 0; + const books = (await db + .select() + .from(audiobooks) + .where(eq(audiobooks.userId, fromUserId))) as AudiobookRow[]; + if (books.length === 0) return 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); + if (isS3Configured()) { + for (const book of books) { + await moveAudiobookBlobScope(book.id, fromUserId, toUserId, namespace); } } - return transferred; + await db + .insert(audiobooks) + .values(books.map((book) => ({ ...book, userId: toUserId }))) + .onConflictDoNothing(); + + const chapters = (await db + .select() + .from(audiobookChapters) + .where(eq(audiobookChapters.userId, fromUserId))) as AudiobookChapterRow[]; + if (chapters.length > 0) { + await db + .insert(audiobookChapters) + .values(chapters.map((chapter) => ({ ...chapter, userId: toUserId }))) + .onConflictDoNothing(); + } + + await db.delete(audiobookChapters).where(eq(audiobookChapters.userId, fromUserId)); + await db.delete(audiobooks).where(eq(audiobooks.userId, fromUserId)); + + return books.length; } diff --git a/src/lib/server/db-indexing.ts b/src/lib/server/db-indexing.ts deleted file mode 100644 index 1572fcd..0000000 --- a/src/lib/server/db-indexing.ts +++ /dev/null @@ -1,186 +0,0 @@ -import fs from 'fs/promises'; -import { existsSync } from 'fs'; -import path from 'path'; -import { isAuthEnabled } from '@/lib/server/auth-config'; -import { db } from '@/db'; -import { audiobookChapters, audiobooks, documents } from '@/db/schema'; -import { and, count, eq } from 'drizzle-orm'; -import { listStoredChapters } from '@/lib/server/audiobook'; -import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUnclaimedAudiobookDir } from '@/lib/server/docstore'; - -const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); -const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); -const STATE_PATH = path.join(MIGRATIONS_DIR, 'db-index.json'); - -type DbIndexState = { - indexedAt: number; - mode: 'auth' | 'noauth'; -}; - -let inflight: Promise | null = null; -let memoryIndexedMode: DbIndexState['mode'] | null = null; - -async function readState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, 'utf8'); - return JSON.parse(raw) as DbIndexState; - } catch { - return null; - } -} - -async function writeState(): Promise { - await fs.mkdir(MIGRATIONS_DIR, { recursive: true }); - const state: DbIndexState = { - indexedAt: Date.now(), - mode: isAuthEnabled() ? 'auth' : 'noauth', - }; - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2)); -} - -async function hasAudiobookFilesystemContent(mode: DbIndexState['mode']): Promise { - const audiobookDir = mode === 'auth' ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; - try { - const entries = await fs.readdir(audiobookDir, { withFileTypes: true }); - return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); - } catch { - return false; - } -} - -async function isAudiobookIndexedForUser(id: string, userId: string): Promise { - const result = await db - .select({ id: audiobooks.id }) - .from(audiobooks) - .where(and(eq(audiobooks.id, id), eq(audiobooks.userId, userId))); - return result.length > 0; -} - -async function migrateLegacyAudiobooksToUnclaimed(): Promise { - if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; - - const unclaimedDir = getUnclaimedAudiobookDir(); - await fs.mkdir(unclaimedDir, { recursive: true }); - - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - let migrated = 0; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - const targetDir = path.join(unclaimedDir, entry.name); - 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 getUnclaimedCounts(): Promise<{ documents: number; audiobooks: number }> { - 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: Number(docCount?.count ?? 0), - audiobooks: Number(bookCount?.count ?? 0), - }; -} - -async function scanAndPopulateAudiobookDb(): Promise { - const authEnabled = isAuthEnabled(); - console.log('Scanning file system for un-indexed audiobooks...'); - - if (authEnabled) { - await migrateLegacyAudiobooksToUnclaimed(); - } - - const audiobookScanDir = authEnabled ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; - if (!existsSync(audiobookScanDir)) return; - - const entries = await fs.readdir(audiobookScanDir, { 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 isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - - const dirPath = path.join(audiobookScanDir, entry.name); - - let title = 'Unknown Title'; - try { - const metaPath = path.join(dirPath, 'audiobook.meta.json'); - const metaContent = await fs.readFile(metaPath, 'utf8'); - JSON.parse(metaContent); - } catch { - // ignore - } - - const chapters = await listStoredChapters(dirPath); - const totalDuration = chapters.reduce((acc, chapter) => acc + (chapter.durationSec || 0), 0); - if (chapters.length > 0) title = chapters[0].title || title; - - await db.insert(audiobooks).values({ - id: bookId, - userId: UNCLAIMED_USER_ID, - title, - duration: totalDuration, - }); - console.log(`Indexed audiobook: ${bookId}`); - - for (const chapter of chapters) { - await db.insert(audiobookChapters).values({ - id: `${bookId}-${chapter.index}`, - bookId, - userId: UNCLAIMED_USER_ID, - chapterIndex: chapter.index, - title: chapter.title, - duration: chapter.durationSec || 0, - filePath: chapter.filePath, - format: chapter.format, - }); - } - } -} - -export async function ensureAudiobooksIndexed(): Promise { - const mode: DbIndexState['mode'] = isAuthEnabled() ? 'auth' : 'noauth'; - if (memoryIndexedMode === mode) return; - - inflight ??= (async () => { - const hasState = existsSync(STATE_PATH) ? await readState() : null; - if (hasState && hasState.mode === mode) { - const [counts, fsHasAudiobooks] = await Promise.all([ - getUnclaimedCounts(), - hasAudiobookFilesystemContent(mode), - ]); - const audiobooksOk = counts.audiobooks > 0 || !fsHasAudiobooks; - if (audiobooksOk) { - memoryIndexedMode = mode; - return; - } - } - - await fs.mkdir(DOCSTORE_DIR, { recursive: true }); - await scanAndPopulateAudiobookDb(); - await writeState(); - memoryIndexedMode = mode; - })().finally(() => { - inflight = null; - }); - - await inflight; -} - -export async function ensureDbIndexed(): Promise { - await ensureAudiobooksIndexed(); -} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 79e76a7..3ed1d5a 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -1,210 +1,11 @@ import { createHash } from 'crypto'; -import { spawn } from 'child_process'; -import { existsSync } from 'fs'; -import { mkdir, readdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from 'fs/promises'; import path from 'path'; -import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ffprobeAudio } from '@/lib/server/audiobook'; 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_v1'); export const UNCLAIMED_USER_ID = 'unclaimed'; -const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; - -/** - * Get the audiobook directory for a specific user when auth is enabled. - * Returns path like: docstore/audiobooks_users_v1/{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_v1/unclaimed - */ -export function getUnclaimedAudiobookDir(): string { - return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID); -} - -/** - * Resolve the base audiobooks directory for request context, including optional test namespace. - * - When auth is disabled or userId is absent: docstore/audiobooks_v1[/] - * - When auth is enabled: docstore/audiobooks_users_v1/{userId}[/] - */ -export function getAudiobooksRootDir({ - userId, - authEnabled, - namespace, -}: { - userId: string | null; - authEnabled: boolean; - namespace: string | null; -}): string { - const baseDir = !authEnabled || !userId ? AUDIOBOOKS_V1_DIR : getUserAudiobookDir(userId); - if (!namespace) return baseDir; - - const safe = namespace.trim(); - if (!safe || safe === '.' || safe === '..' || safe.includes('..')) return baseDir; - if (!SAFE_NAMESPACE_REGEX.test(safe)) return baseDir; - - const resolved = path.resolve(baseDir, safe); - if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; - return resolved; -} - -/** - * Move an audiobook folder from one user's directory to another. - * Used for claiming unclaimed audiobooks or transferring on account linking. - * @returns true if moved successfully, false if source doesn't exist - */ -export async function moveAudiobookToUser( - bookId: string, - fromUserId: string, - toUserId: string -): Promise { - const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`); - const targetUserDir = getUserAudiobookDir(toUserId); - const targetDir = path.join(targetUserDir, `${bookId}-audiobook`); - - if (!existsSync(sourceDir)) { - return false; - } - - // Ensure target user directory exists - await mkdir(targetUserDir, { recursive: true }); - - // If target already exists, we need to merge or skip - if (existsSync(targetDir)) { - // Target exists - merge contents (move files that don't exist in target) - const result = await mergeDirectoryContents(sourceDir, targetDir); - // Try to remove source if empty - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir, { recursive: true, force: true }); - } - } catch { /* ignore */ } - return result.moved > 0 || result.skipped > 0; - } - - // Simple rename/move - await rename(sourceDir, targetDir); - return true; -} - -/** - * List all audiobook IDs in a user's directory. - */ -export async function listUserAudiobookIds(userId: string): Promise { - const userDir = getUserAudiobookDir(userId); - if (!existsSync(userDir)) return []; - - const entries = await readdir(userDir, { withFileTypes: true }); - const bookIds: string[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - bookIds.push(entry.name.replace('-audiobook', '')); - } - - return bookIds; -} - -const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); -const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); - -type MigrationState = { - documentsV1Migrated?: boolean; - audiobooksV1Migrated?: boolean; - updatedAt?: number; -}; - -type LegacyDocumentMetadata = { - id: string; - name: string; - size: number; - lastModified: number; - type: string; -}; - -function isLegacyDocumentMetadata(value: unknown): value is LegacyDocumentMetadata { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.id === 'string' && - typeof v.name === 'string' && - typeof v.size === 'number' && - typeof v.lastModified === 'number' && - typeof v.type === 'string' - ); -} - -async function loadMigrationState(): Promise { - try { - return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState; - } catch { - return {}; - } -} - -async function saveMigrationState(update: Partial): Promise { - const state = await loadMigrationState(); - const next: MigrationState = { - documentsV1Migrated: state.documentsV1Migrated, - audiobooksV1Migrated: state.audiobooksV1Migrated, - ...update, - updatedAt: Date.now(), - }; - await mkdir(MIGRATIONS_DIR, { recursive: true }); - await writeFile(MIGRATIONS_STATE_PATH, JSON.stringify(next, null, 2)); -} - -async function hasLegacyDocumentFiles(): Promise { - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - return false; - } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.json')) continue; - - const metadataPath = path.join(DOCSTORE_DIR, entry.name); - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(metadataPath, 'utf8')); - } catch { - continue; - } - if (!isLegacyDocumentMetadata(parsed)) continue; - - const contentPath = path.join(DOCSTORE_DIR, `${parsed.id}.${parsed.type}`); - if (!existsSync(contentPath)) continue; - - return true; - } - - return false; -} - -export async function isDocumentsV1Ready(): Promise { - if (!existsSync(DOCSTORE_DIR) || !existsSync(DOCUMENTS_V1_DIR)) return false; - const state = await loadMigrationState(); - if (!state.documentsV1Migrated) return false; - if (await hasLegacyDocumentFiles()) return false; - return true; -} function safeDocumentName(rawName: string, fallback: string): string { const baseName = path.basename(rawName || fallback); @@ -212,456 +13,15 @@ function safeDocumentName(rawName: string, fallback: string): string { } export function getMigratedDocumentFileName(id: string, name: string): string { + const normalizedName = safeDocumentName(name, `${id}.bin`); const prefix = `${id}__`; - const encodedName = encodeURIComponent(name); + const encodedName = encodeURIComponent(normalizedName); let targetFileName = `${prefix}${encodedName}`; - // Ensure total filename length is within safe limits (e.g. 240 chars). - // If too long, use a deterministic hash of the name instead of the full encoded name. + // Keep migrated document filenames under conservative filesystem length limits. if (targetFileName.length > 240) { - const nameHash = createHash('sha256').update(name).digest('hex').slice(0, 32); + const nameHash = createHash('sha256').update(normalizedName).digest('hex').slice(0, 32); targetFileName = `${prefix}truncated-${nameHash}`; } return targetFileName; } - -export async function ensureDocumentsV1Ready(): Promise { - await mkdir(DOCSTORE_DIR, { recursive: true }); - await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); - - const state = await loadMigrationState(); - if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) { - return false; - } - - if (!(await hasLegacyDocumentFiles())) { - await saveMigrationState({ documentsV1Migrated: true }); - return false; - } - - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - entries = []; - } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.json')) continue; - - const metadataPath = path.join(DOCSTORE_DIR, entry.name); - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(metadataPath, 'utf8')); - } catch { - continue; - } - if (!isLegacyDocumentMetadata(parsed)) continue; - const metadata = parsed; - - const contentPath = path.join(DOCSTORE_DIR, `${metadata.id}.${metadata.type}`); - let contentStat: Awaited>; - try { - contentStat = await stat(contentPath); - } catch { - continue; - } - if (!contentStat.isFile()) continue; - - const content = await readFile(contentPath); - const id = createHash('sha256').update(content).digest('hex'); - const fallbackName = `${id}.${metadata.type}`; - const name = safeDocumentName(metadata.name, fallbackName); - - const targetFileName = getMigratedDocumentFileName(id, name); - const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); - - if (!existsSync(targetPath)) { - await writeFile(targetPath, content); - if (Number.isFinite(metadata.lastModified) && metadata.lastModified > 0) { - const stamp = new Date(metadata.lastModified); - await utimes(targetPath, stamp, stamp).catch(() => { }); - } - } - - await unlink(metadataPath).catch(() => { }); - await unlink(contentPath).catch(() => { }); - } - - await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) }); - return true; -} - -async function hasLegacyAudiobookDirs(): Promise { - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - return false; - } - - return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); -} - -async function hasLegacyAudiobookChapterLayout(): Promise { - let entries: Array = []; - try { - entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - } catch { - return false; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - let files: string[] = []; - try { - files = await readdir(dir); - } catch { - continue; - } - - for (const file of files) { - // Per-audiobook settings file is the new format; ignore it. - if (file === 'audiobook.meta.json') continue; - - if (file.endsWith('.meta.json')) return true; - if (/^\d+-chapter\.(mp3|m4b)$/i.test(file)) return true; - if (/^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)) return true; - } - } - - return false; -} - -export async function isAudiobooksV1Ready(): Promise { - if (!existsSync(DOCSTORE_DIR) || !existsSync(AUDIOBOOKS_V1_DIR)) return false; - const state = await loadMigrationState(); - if (!state.audiobooksV1Migrated) return false; - const legacyDirsPresent = await hasLegacyAudiobookDirs(); - const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); - if (legacyDirsPresent || legacyChaptersPresent) return false; - return true; -} - -async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { - let moved = 0; - let skipped = 0; - - let entries: Array = []; - try { - entries = await readdir(sourceDir, { withFileTypes: true }); - } catch { - return { moved, skipped }; - } - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - await mkdir(targetPath, { recursive: true }); - const nested = await mergeDirectoryContents(sourcePath, targetPath); - moved += nested.moved; - skipped += nested.skipped; - - try { - const remaining = await readdir(sourcePath); - if (remaining.length === 0) { - await rm(sourcePath); - } - } catch { } - continue; - } - - if (!entry.isFile()) continue; - - if (existsSync(targetPath)) { - skipped++; - continue; - } - - try { - await rename(sourcePath, targetPath); - moved++; - } catch { - skipped++; - } - } - - return { moved, skipped }; -} - -export async function ensureAudiobooksV1Ready(): Promise { - await mkdir(DOCSTORE_DIR, { recursive: true }); - await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); - // Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios - await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true }); - - const state = await loadMigrationState(); - const legacyDirsPresent = await hasLegacyAudiobookDirs(); - const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); - - if (state.audiobooksV1Migrated && !legacyDirsPresent && !legacyChaptersPresent) { - const stateRaw = state as unknown as Record; - const allowedKeys = new Set(['documentsV1Migrated', 'audiobooksV1Migrated', 'updatedAt']); - const hasExtraKeys = Object.keys(stateRaw).some((key) => !allowedKeys.has(key)); - if (hasExtraKeys) { - await saveMigrationState({ audiobooksV1Migrated: true }); - } - return false; - } - - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - entries = []; - } - - if (legacyDirsPresent) { - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const sourceDir = path.join(DOCSTORE_DIR, entry.name); - const targetDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - - try { - if (!existsSync(targetDir)) { - await rename(sourceDir, targetDir); - continue; - } - - await mkdir(targetDir, { recursive: true }); - await mergeDirectoryContents(sourceDir, targetDir); - - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir); - } else { - console.warn(`Legacy audiobook dir not fully migrated (kept): ${sourceDir}`); - } - } catch { } - } catch (error) { - console.error('Error migrating legacy audiobook directory:', error); - throw error; - } - } - } - - if (legacyDirsPresent || legacyChaptersPresent) { - await normalizeAudiobookChapterLayout(); - } - - const finalLegacyRemaining = await hasLegacyAudiobookDirs(); - const finalLegacyChaptersRemaining = await hasLegacyAudiobookChapterLayout(); - await saveMigrationState({ audiobooksV1Migrated: !finalLegacyRemaining && !finalLegacyChaptersRemaining }); - return true; -} - -type LegacyChapterMeta = { - title?: string; - duration?: number; - index?: number; - format?: string; -}; - -async function runProcess(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args); - let stderr = ''; - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`${command} exited with code ${code}: ${stderr}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -async function rewriteAudioTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise { - const baseArgs = ['-y', '-i', inputPath, '-metadata', `title=${titleTag}`]; - if (format === 'mp3') { - await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-write_id3v2', '1', '-id3v2_version', '3', outputPath]); - return; - } - await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-f', 'mp4', outputPath]); -} - -async function transcodeWithTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise { - if (format === 'mp3') { - await runProcess('ffmpeg', [ - '-y', - '-i', - inputPath, - '-c:a', - 'libmp3lame', - '-b:a', - '64k', - '-metadata', - `title=${titleTag}`, - outputPath, - ]); - return; - } - - await runProcess('ffmpeg', [ - '-y', - '-i', - inputPath, - '-c:a', - 'aac', - '-b:a', - '64k', - '-metadata', - `title=${titleTag}`, - '-f', - 'mp4', - outputPath, - ]); -} - -async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string): Promise { - let files: string[] = []; - try { - files = await readdir(intermediateDir); - } catch { - return; - } - - // Remove any combined output files from older layouts. - await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => { }); - await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => { }); - await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => { }); - await unlink(path.join(intermediateDir, 'list.txt')).catch(() => { }); - - const metaFiles = files.filter((file) => file.endsWith('.meta.json')); - const migratedIndices = new Set(); - - for (const metaFile of metaFiles) { - const metaPath = path.join(intermediateDir, metaFile); - let metaRaw: unknown; - try { - metaRaw = JSON.parse(await readFile(metaPath, 'utf8')); - } catch { - continue; - } - const meta = metaRaw as LegacyChapterMeta; - const index = Number(meta.index); - if (!Number.isFinite(index) || !Number.isInteger(index) || index < 0) continue; - - const format = meta.format === 'mp3' ? 'mp3' : 'm4b'; - const sourceAudio = path.join(intermediateDir, `${index}-chapter.${format}`); - if (!existsSync(sourceAudio)) { - await unlink(metaPath).catch(() => { }); - continue; - } - - const titleTag = encodeChapterTitleTag(index, meta.title ?? `Chapter ${index + 1}`); - const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); - - try { - await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); - } catch { - await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); - } - - const finalName = encodeChapterFileName(index, meta.title ?? `Chapter ${index + 1}`, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(taggedTemp, finalPath); - - await unlink(sourceAudio).catch(() => { }); - await unlink(metaPath).catch(() => { }); - migratedIndices.add(index); - } - - // Migrate any remaining legacy chapter files without metadata. - files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - const match = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(file); - if (!match) continue; - const index = Number(match[1]); - if (!Number.isInteger(index) || index < 0) continue; - if (migratedIndices.has(index)) continue; - - const format = match[2].toLowerCase() as 'mp3' | 'm4b'; - const sourceAudio = path.join(intermediateDir, file); - const titleTag = encodeChapterTitleTag(index, `Chapter ${index + 1}`); - const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); - - try { - await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); - } catch { - await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); - } - - const finalName = encodeChapterFileName(index, `Chapter ${index + 1}`, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(taggedTemp, finalPath); - - await unlink(sourceAudio).catch(() => { }); - } - - // Rename any sha-named chapter files from previous runs into the index__title scheme. - files = await readdir(intermediateDir).catch(() => []); - const shaCandidates = files.filter((file) => /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)); - for (const file of shaCandidates) { - const sourceAudio = path.join(intermediateDir, file); - const format = file.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b'; - - let decoded: { index: number; title: string } | null = null; - try { - const probe = await ffprobeAudio(sourceAudio); - decoded = probe.titleTag ? decodeChapterTitleTag(probe.titleTag) : null; - } catch { - decoded = null; - } - if (!decoded) continue; - - const finalName = encodeChapterFileName(decoded.index, decoded.title, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(sourceAudio, finalPath).catch(() => { }); - } - - // Remove any leftover input temp files. - files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - if (/^\d+-input\.mp3$/i.test(file)) { - await unlink(path.join(intermediateDir, file)).catch(() => { }); - } - if (file.endsWith('.meta.json') && file !== 'audiobook.meta.json') { - await unlink(path.join(intermediateDir, file)).catch(() => { }); - } - } -} - -async function normalizeAudiobookChapterLayout(): Promise { - let entries: Array = []; - try { - entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - try { - await normalizeAudiobookDirectoryChapterLayout(dir); - } catch (error) { - console.error('Error migrating audiobook chapter layout:', error); - throw error; - } - } -} diff --git a/src/lib/server/ffmpeg-bin.ts b/src/lib/server/ffmpeg-bin.ts new file mode 100644 index 0000000..dabd7ab --- /dev/null +++ b/src/lib/server/ffmpeg-bin.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'fs'; +import ffmpegStatic from 'ffmpeg-static'; +import ffprobeStatic from 'ffprobe-static'; + +function normalizePath(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string { + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + if (!bundledValue) { + throw new Error( + `${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`, + ); + } + + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + return bundledValue; +} + +export function getFFmpegPath(): string { + return resolveBinary( + normalizePath(process.env.FFMPEG_BIN), + normalizePath(ffmpegStatic), + 'FFMPEG_BIN', + 'ffmpeg-static', + ); +} + +export function getFFprobePath(): string { + return resolveBinary( + normalizePath(process.env.FFPROBE_BIN), + normalizePath(ffprobeStatic?.path), + 'FFPROBE_BIN', + 'ffprobe-static', + ); +} diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index fb2bea2..81b7319 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -1,11 +1,106 @@ +import { ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { and, eq, inArray, like } from 'drizzle-orm'; +import { db } from '../src/db'; +import { audiobooks, audiobookChapters, documents } from '../src/db/schema'; import { deleteDocumentPrefix } from '../src/lib/server/documents-blobstore'; -import { getS3Config, isS3Configured } from '../src/lib/server/s3'; +import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks-blobstore'; +import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/s3'; + +function chunk(items: T[], size: number): T[][] { + if (items.length === 0) return []; + const groups: T[][] = []; + for (let i = 0; i < items.length; i += size) { + groups.push(items.slice(i, i + size)); + } + return groups; +} + +async function listKeysByPrefix(prefix: string): Promise { + const config = getS3Config(); + const client = getS3Client(); + let continuationToken: string | undefined; + const keys: string[] = []; + + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: config.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + + for (const entry of response.Contents ?? []) { + if (entry.Key) keys.push(entry.Key); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return keys; +} + +function parseAudiobookScopeFromKey( + key: string, + audiobooksNsRootPrefix: string, +): { userId: string; bookId: string } | null { + if (!key.startsWith(audiobooksNsRootPrefix)) return null; + const rel = key.slice(audiobooksNsRootPrefix.length); + const parts = rel.split('/'); + // ns//users//-audiobook/ + if (parts.length < 5) return null; + if (parts[1] !== 'users') return null; + const encodedUserId = parts[2]; + const dirName = parts[3]; + if (!encodedUserId || !dirName.endsWith('-audiobook')) return null; + const bookId = dirName.slice(0, -'-audiobook'.length); + if (!bookId) return null; + + let userId: string; + try { + userId = decodeURIComponent(encodedUserId); + } catch { + return null; + } + return { userId, bookId }; +} export default async function globalTeardown(): Promise { + // Always clear namespaced no-auth SQL rows from prior runs. + await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%')); + await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); + await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); + if (!isS3Configured()) return; const config = getS3Config(); - const nsRootPrefix = `${config.prefix}/documents_v1/ns/`; - await deleteDocumentPrefix(nsRootPrefix); -} + const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`; + const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`; + // Remove SQL audiobook rows for namespaced objects (covers auth claim flows too). + const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix); + const byUser = new Map>(); + for (const key of audiobookKeys) { + const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix); + if (!scope) continue; + let set = byUser.get(scope.userId); + if (!set) { + set = new Set(); + byUser.set(scope.userId, set); + } + set.add(scope.bookId); + } + + for (const [userId, bookIds] of byUser) { + for (const ids of chunk(Array.from(bookIds), 200)) { + await db + .delete(audiobookChapters) + .where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, ids))); + await db + .delete(audiobooks) + .where(and(eq(audiobooks.userId, userId), inArray(audiobooks.id, ids))); + } + } + + await deleteDocumentPrefix(docsNsRootPrefix); + await deleteAudiobookPrefix(audiobooksNsRootPrefix); +} diff --git a/tests/unit/audiobooks-blobstore.spec.ts b/tests/unit/audiobooks-blobstore.spec.ts new file mode 100644 index 0000000..a64087e --- /dev/null +++ b/tests/unit/audiobooks-blobstore.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { + audiobookKey, + audiobookPrefix, + isMissingBlobError, + isPreconditionFailed, +} from '../../src/lib/server/audiobooks-blobstore'; + +function configureS3Env() { + process.env.S3_BUCKET = process.env.S3_BUCKET || 'test-bucket'; + process.env.S3_REGION = process.env.S3_REGION || 'us-east-1'; + process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || 'test-access'; + process.env.S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || 'test-secret'; + process.env.S3_PREFIX = 'openreader-test'; +} + +test.describe('audiobooks-blobstore', () => { + test.beforeAll(() => { + configureS3Env(); + }); + + test('builds audiobook prefix with namespace', () => { + const prefix = audiobookPrefix('book-123', 'user-abc', 'worker1'); + expect(prefix).toBe('openreader-test/audiobooks_v1/ns/worker1/users/user-abc/book-123-audiobook/'); + }); + + test('builds audiobook prefix without namespace', () => { + const prefix = audiobookPrefix('book-123', 'unclaimed', null); + expect(prefix).toBe('openreader-test/audiobooks_v1/users/unclaimed/book-123-audiobook/'); + }); + + test('builds key for chapter file', () => { + const key = audiobookKey('book-123', 'user-abc', '0001__Chapter%201.mp3', null); + expect(key).toBe('openreader-test/audiobooks_v1/users/user-abc/book-123-audiobook/0001__Chapter%201.mp3'); + }); + + test('rejects invalid file names in keys', () => { + expect(() => audiobookKey('book-123', 'user-abc', '../bad.mp3', null)).toThrow(/Invalid audiobook file name/); + }); + + test('detects missing blob errors', () => { + expect(isMissingBlobError({ name: 'NoSuchKey' })).toBeTruthy(); + expect(isMissingBlobError({ $metadata: { httpStatusCode: 404 } })).toBeTruthy(); + expect(isMissingBlobError({ name: 'AccessDenied' })).toBeFalsy(); + }); + + test('detects precondition failed errors', () => { + expect(isPreconditionFailed({ name: 'PreconditionFailed' })).toBeTruthy(); + expect(isPreconditionFailed({ $metadata: { httpStatusCode: 412 } })).toBeTruthy(); + expect(isPreconditionFailed({ $metadata: { httpStatusCode: 409 } })).toBeFalsy(); + }); +}); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..553c3cf --- /dev/null +++ b/vercel.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "functions": { + "app/api/audiobook/route.ts": { + "memory": 3009 + }, + "app/api/whisper/route.ts": { + "memory": 3009 + } + } +}