refactor(audiobooks): migrate audiobook pipeline to object storage

This commit restructures the audiobook generation and serving layer to rely exclusively on S3-compatible blob storage, removing the dependency on local filesystem paths.

- Bundle `ffmpeg` and `ffprobe` binaries via npm to ensure portability across environments.
- Update API routes to stream audio directly from blob storage instead of local disk.
- Remove legacy migration endpoints and filesystem-based indexing logic.
- Add startup scripts to facilitate the transition from local to remote storage.

BREAKING CHANGE: Audiobook functionality is now strictly dependent on S3 configuration. The previous filesystem-based storage method has been removed.
This commit is contained in:
Richard R 2026-02-11 02:44:34 -07:00
parent 91bcf232e1
commit 9b9206f50d
49 changed files with 2968 additions and 2573 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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).

View file

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

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' },
],

View file

@ -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'],
},
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

View file

@ -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",

File diff suppressed because it is too large Load diff

919
scripts/migrate-fs-v2.mjs Normal file
View file

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

View file

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

View file

@ -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<Uint8Array> {
return new ReadableStream<Uint8Array>({
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 });
}
}

View file

@ -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<number, ChapterObject>();
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<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
return new Promise<void>((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<number> {
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<void> {
return new Promise<void>((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<number, number>();
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 });
}
}

View file

@ -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<number, ChapterObject>();
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<number, number>();
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 });
}
}

View file

@ -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<import('fs').Dirent> = [];
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 });
}
}

View file

@ -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<DocumentType, 'html'> | 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<number> {
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<number> {
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<string>();
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 });
}
}

View file

@ -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<NextResponse | null> {
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<NextResponse | null> {
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,

View file

@ -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<void>((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
const ffmpeg = spawn(getFFmpegPath(), [
'-y',
'-i',
inputPath,

View file

@ -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() {
</div>
)}
</div>
{isDev && (
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}

View file

@ -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';
// Dynamic import for client-side rendering only
const PDFViewer = dynamic(
@ -154,7 +154,7 @@ export default function PDFViewerPage() {
onZoomDecrease={handleZoomOut}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
isDev={isDev}
showAudiobookExport={canExportAudiobook}
minZoom={50}
maxZoom={300}
/>
@ -171,7 +171,7 @@ export default function PDFViewerPage() {
<PDFViewer zoomLevel={zoomLevel} />
)}
</div>
{isDev && (
{canExportAudiobook && (
<AudiobookExportModal
isOpen={isAudiobookModalOpen}
setIsOpen={setIsAudiobookModalOpen}

View file

@ -12,7 +12,7 @@ interface DocumentHeaderMenuProps {
onZoomDecrease: () => 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 && (
<button
onClick={onOpenAudiobook}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
@ -102,7 +102,7 @@ export function DocumentHeaderMenu({
{/* Actions Section */}
<div className="p-1">
{isDev && onOpenAudiobook && (
{showAudiobookExport && onOpenAudiobook && (
<MenuItem>
{({ active }) => (
<button

View file

@ -12,6 +12,8 @@ import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
const canWordHighlight = isDev || process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true';
const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' },
@ -151,9 +153,9 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-[1] disabled:hover:bg-accent"
onClick={() => setIsAudiobookModalOpen(true)}
disabled={!isDev}
disabled={!canExportAudiobook}
>
Export Audiobook {!isDev && '(requires self-hosted)'}
Export Audiobook {!canExportAudiobook && '(disabled by configuration)'}
</Button>
</div>}
@ -355,7 +357,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
<input
type="checkbox"
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
disabled={!pdfHighlightEnabled || !isDev}
disabled={!pdfHighlightEnabled || !canWordHighlight}
onChange={(e) =>
updateConfigKey('pdfWordHighlightEnabled', e.target.checked)
}
@ -366,7 +368,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
</span>
</label>
<p className="text-sm text-muted pl-6">
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)'}
</p>
</div>
</div>
@ -392,7 +394,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
<input
type="checkbox"
checked={epubWordHighlightEnabled && epubHighlightEnabled}
disabled={!epubHighlightEnabled || !isDev}
disabled={!epubHighlightEnabled || !canWordHighlight}
onChange={(e) =>
updateConfigKey('epubWordHighlightEnabled', e.target.checked)
}
@ -403,7 +405,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
</span>
</label>
<p className="text-sm text-muted pl-6">
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)'}
</p>
</div>
</div>

View file

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

View file

@ -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<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => {
const ffprobe = spawn('ffprobe', [
const ffprobe = spawn(getFFprobePath(), [
'-v',
'quiet',
'-print_format',

View file

@ -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<Buffer> {
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<Buffer> {
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<Uint8Array> };
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<AudiobookBlobObject[]> {
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<Buffer> {
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<unknown> {
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<void> {
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<void> {
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<number> {
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;
}

View file

@ -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<void> {
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<number> {
export async function transferUserAudiobooks(
fromUserId: string,
toUserId: string,
namespace: string | null = null,
): Promise<number> {
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;
}

View file

@ -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<void> | null = null;
let memoryIndexedMode: DbIndexState['mode'] | null = null;
async function readState(): Promise<DbIndexState | null> {
try {
const raw = await fs.readFile(STATE_PATH, 'utf8');
return JSON.parse(raw) as DbIndexState;
} catch {
return null;
}
}
async function writeState(): Promise<void> {
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<boolean> {
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<boolean> {
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<number> {
if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0;
const unclaimedDir = getUnclaimedAudiobookDir();
await fs.mkdir(unclaimedDir, { recursive: true });
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
let migrated = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name);
const targetDir = path.join(unclaimedDir, entry.name);
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<void> {
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<void> {
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<void> {
await ensureAudiobooksIndexed();
}

View file

@ -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[/<namespace>]
* - When auth is enabled: docstore/audiobooks_users_v1/{userId}[/<namespace>]
*/
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<boolean> {
const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`);
const targetUserDir = getUserAudiobookDir(toUserId);
const targetDir = path.join(targetUserDir, `${bookId}-audiobook`);
if (!existsSync(sourceDir)) {
return false;
}
// Ensure target user directory exists
await mkdir(targetUserDir, { recursive: true });
// If target already exists, we need to merge or skip
if (existsSync(targetDir)) {
// Target exists - merge contents (move files that don't exist in target)
const result = await mergeDirectoryContents(sourceDir, targetDir);
// Try to remove source if empty
try {
const remaining = await readdir(sourceDir);
if (remaining.length === 0) {
await rm(sourceDir, { recursive: true, force: true });
}
} catch { /* ignore */ }
return result.moved > 0 || result.skipped > 0;
}
// Simple rename/move
await rename(sourceDir, targetDir);
return true;
}
/**
* List all audiobook IDs in a user's directory.
*/
export async function listUserAudiobookIds(userId: string): Promise<string[]> {
const userDir = getUserAudiobookDir(userId);
if (!existsSync(userDir)) return [];
const entries = await readdir(userDir, { withFileTypes: true });
const bookIds: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
bookIds.push(entry.name.replace('-audiobook', ''));
}
return bookIds;
}
const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations');
const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json');
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<string, unknown>;
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<MigrationState> {
try {
return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState;
} catch {
return {};
}
}
async function saveMigrationState(update: Partial<MigrationState>): Promise<void> {
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<boolean> {
let entries: Array<import('fs').Dirent> = [];
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<boolean> {
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<boolean> {
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<import('fs').Dirent> = [];
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<ReturnType<typeof stat>>;
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<boolean> {
let entries: Array<import('fs').Dirent> = [];
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<boolean> {
let entries: Array<import('fs').Dirent> = [];
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<boolean> {
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<import('fs').Dirent> = [];
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<boolean> {
await mkdir(DOCSTORE_DIR, { recursive: true });
await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true });
// Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios
await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true });
const state = await loadMigrationState();
const legacyDirsPresent = await hasLegacyAudiobookDirs();
const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout();
if (state.audiobooksV1Migrated && !legacyDirsPresent && !legacyChaptersPresent) {
const stateRaw = state as unknown as Record<string, unknown>;
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<import('fs').Dirent> = [];
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<void> {
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<void> {
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<void> {
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<void> {
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<number>();
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<void> {
let entries: Array<import('fs').Dirent> = [];
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;
}
}
}

View file

@ -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',
);
}

View file

@ -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<T>(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<string[]> {
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/<namespace>/users/<userId>/<bookId>-audiobook/<file>
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<void> {
// 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<string, Set<string>>();
for (const key of audiobookKeys) {
const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix);
if (!scope) continue;
let set = byUser.get(scope.userId);
if (!set) {
set = new Set<string>();
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);
}

View file

@ -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();
});
});

11
vercel.json Normal file
View file

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