Compare commits
No commits in common. "main" and "v4.1.1" have entirely different histories.
587 changed files with 8843 additions and 47516 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
**/*.creds
|
|
||||||
README.md
|
README.md
|
||||||
.next
|
.next
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
10
.env.example
10
.env.example
|
|
@ -14,11 +14,11 @@
|
||||||
# remove them once the seed has run. Auth must be configured for this seed path.
|
# remove them once the seed has run. Auth must be configured for this seed path.
|
||||||
# See Settings → Admin → Shared providers.
|
# See Settings → Admin → Shared providers.
|
||||||
API_BASE=http://localhost:8880/v1
|
API_BASE=http://localhost:8880/v1
|
||||||
API_KEY=
|
API_KEY=api_key_optional
|
||||||
|
|
||||||
# Auth configuration (required in v4+)
|
# Auth configuration (required in v4+)
|
||||||
BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network)
|
BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network)
|
||||||
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32`
|
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
|
||||||
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
|
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
|
||||||
# (Optional) Allow anonymous auth sessions (default: `false`)
|
# (Optional) Allow anonymous auth sessions (default: `false`)
|
||||||
# USE_ANONYMOUS_AUTH_SESSIONS=false
|
# USE_ANONYMOUS_AUTH_SESSIONS=false
|
||||||
|
|
@ -29,10 +29,6 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr
|
||||||
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
|
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
|
||||||
ADMIN_EMAILS=
|
ADMIN_EMAILS=
|
||||||
|
|
||||||
# (Required for the Vercel scheduled-task cron route; Vercel sends it as a bearer token.)
|
|
||||||
# Self-hosted deployments run scheduled tasks in-process and do not require this.
|
|
||||||
# CRON_SECRET=
|
|
||||||
|
|
||||||
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables.
|
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables.
|
||||||
# Defaults to SQLite at docstore/sqlite3.db when not set.
|
# Defaults to SQLite at docstore/sqlite3.db when not set.
|
||||||
# POSTGRES_URL=
|
# POSTGRES_URL=
|
||||||
|
|
@ -92,4 +88,4 @@ S3_BUCKET=
|
||||||
# (Optional) v4 JSON seed for first-boot runtime config + shared providers.
|
# (Optional) v4 JSON seed for first-boot runtime config + shared providers.
|
||||||
# If both are set, RUNTIME_SEED_JSON_PATH is used.
|
# If both are set, RUNTIME_SEED_JSON_PATH is used.
|
||||||
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
|
# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json
|
||||||
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","defaultModel":"kokoro","enabled":true}]}
|
# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]}
|
||||||
|
|
|
||||||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -62,13 +62,13 @@ jobs:
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runner: ubuntu-24.04
|
runner: ubuntu-24.04
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./packages/compute-worker/Dockerfile
|
dockerfile: ./compute/worker/Dockerfile
|
||||||
- image_target: compute-worker
|
- image_target: compute-worker
|
||||||
arch: arm64
|
arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./packages/compute-worker/Dockerfile
|
dockerfile: ./compute/worker/Dockerfile
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
|
||||||
10
.github/workflows/playwright.yml
vendored
10
.github/workflows/playwright.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e-testing:
|
e2e-testing:
|
||||||
timeout-minutes: 30
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
USE_EMBEDDED_WEED_MINI: true
|
USE_EMBEDDED_WEED_MINI: true
|
||||||
|
|
@ -57,12 +57,10 @@ jobs:
|
||||||
env:
|
env:
|
||||||
API_BASE: https://api.deepinfra.com/v1/openai
|
API_BASE: https://api.deepinfra.com/v1/openai
|
||||||
API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||||
run: pnpm exec playwright test --max-failures=5 --reporter=list,github,html
|
run: pnpm exec playwright test --reporter=list,github,html
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
if: ${{ always() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: |
|
path: playwright-report/
|
||||||
playwright-report/
|
|
||||||
tests/results/
|
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
|
||||||
2
.github/workflows/vitest.yml
vendored
2
.github/workflows/vitest.yml
vendored
|
|
@ -25,5 +25,3 @@ jobs:
|
||||||
run: pnpm migrate
|
run: pnpm migrate
|
||||||
- name: Run Vitest suites
|
- name: Run Vitest suites
|
||||||
run: pnpm test:unit
|
run: pnpm test:unit
|
||||||
- name: Verify compute worker contract and boundary
|
|
||||||
run: pnpm compute:openapi:check && pnpm check:compute-boundary
|
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -56,7 +56,5 @@ node_modules/
|
||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
# Agents
|
# .agents
|
||||||
.agents
|
.agents
|
||||||
.codex
|
|
||||||
.claude
|
|
||||||
|
|
|
||||||
39
Dockerfile
39
Dockerfile
|
|
@ -20,9 +20,9 @@ WORKDIR /app
|
||||||
|
|
||||||
# Copy workspace manifests needed for dependency installation
|
# Copy workspace manifests needed for dependency installation
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY packages/bootstrap/package.json ./packages/bootstrap/package.json
|
COPY compute/core/package.json ./compute/core/package.json
|
||||||
COPY packages/compute-worker/package.json ./packages/compute-worker/package.json
|
COPY compute/worker/package.json ./compute/worker/package.json
|
||||||
COPY packages/database/package.json ./packages/database/package.json
|
COPY docker/entrypoint-migration-tools/package.json ./docker/entrypoint-migration-tools/package.json
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
@ -33,9 +33,8 @@ COPY . .
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
RUN pnpm exec next telemetry disable
|
RUN pnpm exec next telemetry disable
|
||||||
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
|
RUN AUTH_SECRET=build-placeholder-secret-value-32chars!! BASE_URL=http://localhost:3003 pnpm build
|
||||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/bootstrap deploy /opt/openreader/bootstrap
|
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/entrypoint-migration-tools deploy /opt/entrypoint-migration-tools
|
||||||
RUN pnpm --dir /opt/openreader/bootstrap rebuild better-sqlite3 ffmpeg-static
|
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/embedded-compute-worker
|
||||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/openreader/embedded-compute-worker
|
|
||||||
# Generate third-party dependency license report plus copied license files.
|
# Generate third-party dependency license report plus copied license files.
|
||||||
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
|
RUN mkdir -p /app/THIRD_PARTY_LICENSES && \
|
||||||
pnpm dlx license-checker-rseidelsohn@4.3.0 \
|
pnpm dlx license-checker-rseidelsohn@4.3.0 \
|
||||||
|
|
@ -64,15 +63,31 @@ COPY --from=app-builder /app/.next/standalone ./
|
||||||
COPY --from=app-builder /app/.next/static ./.next/static
|
COPY --from=app-builder /app/.next/static ./.next/static
|
||||||
COPY --from=app-builder /app/public ./public
|
COPY --from=app-builder /app/public ./public
|
||||||
|
|
||||||
# Ship startup orchestration and the embedded worker as independent deployed bundles.
|
# Copy the entrypoint and migration/runtime helper files it invokes directly.
|
||||||
COPY --from=app-builder /opt/openreader/bootstrap /opt/openreader/bootstrap
|
COPY --from=app-builder /app/scripts/openreader-entrypoint.mjs ./scripts/openreader-entrypoint.mjs
|
||||||
COPY --from=app-builder /opt/openreader/embedded-compute-worker /opt/openreader/embedded-compute-worker
|
COPY --from=app-builder /app/scripts/migrate-fs-v2.mjs ./scripts/migrate-fs-v2.mjs
|
||||||
|
COPY --from=app-builder /app/drizzle ./drizzle
|
||||||
|
COPY --from=app-builder /app/drizzle.config.pg.ts ./drizzle.config.pg.ts
|
||||||
|
COPY --from=app-builder /app/drizzle.config.sqlite.ts ./drizzle.config.sqlite.ts
|
||||||
|
COPY --from=app-builder /app/src/db ./src/db
|
||||||
|
|
||||||
|
# Merge in the dependency subset needed by the entrypoint migration scripts.
|
||||||
|
COPY --from=app-builder /opt/entrypoint-migration-tools/node_modules /tmp/runtime-tools-node_modules
|
||||||
|
RUN mkdir -p /app/node_modules && \
|
||||||
|
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
|
||||||
|
/tmp/runtime-tools-node_modules/better-sqlite3 \
|
||||||
|
/tmp/runtime-tools-node_modules/pg && \
|
||||||
|
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
|
||||||
|
rm -rf /tmp/runtime-tools-node_modules
|
||||||
|
|
||||||
|
# Ship the embedded compute worker as a separate deployed bundle.
|
||||||
|
COPY --from=app-builder /opt/embedded-compute-worker ./embedded-compute-worker
|
||||||
# Include third-party license report and copied license texts at a stable path in the image.
|
# Include third-party license report and copied license texts at a stable path in the image.
|
||||||
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses
|
||||||
# Include SeaweedFS license text for the copied weed binary.
|
# Include SeaweedFS license text for the copied weed binary.
|
||||||
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt
|
||||||
# Include static model notices for runtime-downloaded assets.
|
# Include static model notices for runtime-downloaded assets.
|
||||||
COPY --from=app-builder /app/packages/compute-worker/src/inference/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
COPY --from=app-builder /app/compute/core/src/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt
|
||||||
|
|
||||||
# Copy seaweedfs weed binary for optional embedded local S3.
|
# Copy seaweedfs weed binary for optional embedded local S3.
|
||||||
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed
|
||||||
|
|
@ -82,7 +97,7 @@ COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server
|
||||||
RUN chmod +x /usr/local/bin/nats-server
|
RUN chmod +x /usr/local/bin/nats-server
|
||||||
|
|
||||||
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts.
|
||||||
COPY --from=app-builder /app/packages/compute-worker/src/inference/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt
|
||||||
|
|
||||||
# Match the app's historical container port now that standalone server.js
|
# Match the app's historical container port now that standalone server.js
|
||||||
# is started directly instead of `next start -p 3003`.
|
# is started directly instead of `next start -p 3003`.
|
||||||
|
|
@ -92,5 +107,5 @@ ENV PORT=3003
|
||||||
EXPOSE 3003
|
EXPOSE 3003
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
ENTRYPOINT ["node", "/opt/openreader/bootstrap/src/cli.mjs", "--"]
|
ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
# 📄🔊 OpenReader
|
# 📄🔊 OpenReader
|
||||||
|
|
||||||
OpenReader is an open-source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback.
|
OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with synchronized read-along playback.
|
||||||
|
|
||||||
> Previously named **OpenReader-WebUI**.
|
> Previously named **OpenReader-WebUI**.
|
||||||
|
|
||||||
|
|
@ -22,7 +22,6 @@ OpenReader is an open-source, self-host-friendly text-to-speech document reader
|
||||||
- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed).
|
- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed).
|
||||||
- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback.
|
- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback.
|
||||||
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
|
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
|
||||||
- 🌐 **Multilingual support** — choose a document language for language-aware narration and highlighting. Available languages depend on the configured TTS provider and voice.
|
|
||||||
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
|
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
|
||||||
- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync.
|
- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync.
|
||||||
- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations.
|
- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations.
|
||||||
|
|
|
||||||
21
compute/core/package.json
Normal file
21
compute/core/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@openreader/compute-core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@huggingface/tokenizers": "^0.1.3",
|
||||||
|
"@napi-rs/canvas": "^0.1.100",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"onnxruntime-node": "^1.26.0",
|
||||||
|
"pdfjs-dist": "4.8.69"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./local-runtime": "./src/local-runtime.ts",
|
||||||
|
"./api-contracts": "./src/api-contracts/index.ts",
|
||||||
|
"./control-plane": "./src/control-plane/index.ts",
|
||||||
|
"./types": "./src/types/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,22 @@
|
||||||
import type { TTSSentenceAlignment, ParsedPdfDocument } from '../api/types';
|
import type { TTSSentenceAlignment } from '../types/tts';
|
||||||
|
import type { ParsedPdfDocument } from '../types/parsed-pdf';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
TTSAudioBuffer,
|
TTSAudioBuffer,
|
||||||
TTSAudioBytes,
|
TTSAudioBytes,
|
||||||
TTSSentenceAlignment,
|
TTSSentenceAlignment,
|
||||||
TTSSentenceWord,
|
TTSSentenceWord,
|
||||||
} from '../api/types';
|
} from '../types/tts';
|
||||||
export type {
|
export type {
|
||||||
ParsedPdfBlockKind,
|
ParsedPdfBlockKind,
|
||||||
ParsedPdfBlockFragment,
|
ParsedPdfBlockFragment,
|
||||||
ParsedPdfBlock,
|
ParsedPdfBlock,
|
||||||
ParsedPdfPage,
|
ParsedPdfPage,
|
||||||
ParsedPdfDocument,
|
ParsedPdfDocument,
|
||||||
} from '../api/types';
|
} from '../types/parsed-pdf';
|
||||||
|
|
||||||
export const ALIGN_QUEUE_NAME = 'whisper-align';
|
export const ALIGN_QUEUE_NAME = 'whisper-align';
|
||||||
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
|
export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout';
|
||||||
export const PDF_PARSER_VERSION = 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69';
|
|
||||||
|
|
||||||
export function encodeParserVersion(parserVersion: string, defaultVersion = PDF_PARSER_VERSION): string {
|
|
||||||
return encodeURIComponent(parserVersion.trim() || defaultVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WhisperAlignJobBase {
|
export interface WhisperAlignJobBase {
|
||||||
text: string;
|
text: string;
|
||||||
28
compute/core/src/config/cpu-budget.ts
Normal file
28
compute/core/src/config/cpu-budget.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
function readPositiveInt(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name]?.trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||||
|
return Math.floor(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputeJobConcurrency(): number {
|
||||||
|
return readPositiveInt('COMPUTE_JOB_CONCURRENCY', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableCpuCores(): number {
|
||||||
|
if (typeof os.availableParallelism === 'function') {
|
||||||
|
const value = os.availableParallelism();
|
||||||
|
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
|
||||||
|
}
|
||||||
|
const fallback = os.cpus().length;
|
||||||
|
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOnnxThreadsPerJob(): number {
|
||||||
|
const concurrency = getComputeJobConcurrency();
|
||||||
|
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
|
||||||
|
return Math.max(1, Math.floor(usableCores / concurrency));
|
||||||
|
}
|
||||||
|
|
@ -19,13 +19,7 @@ export type IdleTimeoutAndHardCapInput<T> = {
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function requireEnv(name: string): string {
|
function readPositiveIntEnv(name: string, fallback: number): number {
|
||||||
const value = process.env[name]?.trim();
|
|
||||||
if (!value) throw new Error(`${name} is required`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPositiveIntEnv(name: string, fallback: number): number {
|
|
||||||
const raw = process.env[name]?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
const parsed = Number(raw);
|
const parsed = Number(raw);
|
||||||
|
|
@ -33,34 +27,6 @@ export function readPositiveIntEnv(name: string, fallback: number): number {
|
||||||
return Math.floor(parsed);
|
return Math.floor(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readBoolEnv(name: string, fallback: boolean): boolean {
|
|
||||||
const raw = process.env[name]?.trim();
|
|
||||||
if (!raw) return fallback;
|
|
||||||
return ['1', 'true', 'yes', 'on'].includes(raw.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeNatsReplicas(value: number): number {
|
|
||||||
return value === 3 || value === 5 ? value : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLoggerConfig(): boolean | Record<string, unknown> {
|
|
||||||
const format = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty';
|
|
||||||
const level = process.env.COMPUTE_LOG_LEVEL?.trim() || 'info';
|
|
||||||
if (format === 'json') return { level, base: null };
|
|
||||||
return {
|
|
||||||
level,
|
|
||||||
base: null,
|
|
||||||
transport: {
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true,
|
|
||||||
translateTime: 'SYS:standard',
|
|
||||||
ignore: 'pid,hostname',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutConfigCache: ComputeTimeoutConfig | null = null;
|
let timeoutConfigCache: ComputeTimeoutConfig | null = null;
|
||||||
let opStaleMsCache: number | null = null;
|
let opStaleMsCache: number | null = null;
|
||||||
|
|
||||||
|
|
@ -151,23 +117,3 @@ export async function withIdleTimeoutAndHardCap<T>(input: IdleTimeoutAndHardCapI
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComputeJobConcurrency(): number {
|
|
||||||
return readPositiveIntEnv('COMPUTE_JOB_CONCURRENCY', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAvailableCpuCores(): number {
|
|
||||||
if (typeof os.availableParallelism === 'function') {
|
|
||||||
const value = os.availableParallelism();
|
|
||||||
if (Number.isFinite(value) && value >= 1) return Math.floor(value);
|
|
||||||
}
|
|
||||||
const fallback = os.cpus().length;
|
|
||||||
return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOnnxThreadsPerJob(): number {
|
|
||||||
const concurrency = getComputeJobConcurrency();
|
|
||||||
const usableCores = Math.max(1, getAvailableCpuCores() - 1);
|
|
||||||
return Math.max(1, Math.floor(usableCores / concurrency));
|
|
||||||
}
|
|
||||||
import os from 'node:os';
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './state-machine';
|
export * from './state-machine';
|
||||||
export * from './service';
|
export * from './orchestrator';
|
||||||
export * from './sse';
|
export * from './sse';
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../operations/contracts';
|
} from '../api-contracts';
|
||||||
import {
|
import {
|
||||||
buildQueuedState,
|
buildQueuedState,
|
||||||
createErrorShape,
|
createErrorShape,
|
||||||
|
|
@ -3,8 +3,6 @@ export interface SseFrameInput<T = unknown> {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
data?: T;
|
data?: T;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
/** Reconnection delay (ms) sent to the client EventSource as a `retry:` line. */
|
|
||||||
retry?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
||||||
|
|
@ -12,9 +10,6 @@ export function encodeSseFrame<T = unknown>(input: SseFrameInput<T>): string {
|
||||||
if (typeof input.comment === 'string') {
|
if (typeof input.comment === 'string') {
|
||||||
lines.push(`: ${input.comment}`);
|
lines.push(`: ${input.comment}`);
|
||||||
}
|
}
|
||||||
if (typeof input.retry === 'number' && Number.isFinite(input.retry)) {
|
|
||||||
lines.push(`retry: ${Math.max(0, Math.floor(input.retry))}`);
|
|
||||||
}
|
|
||||||
if (typeof input.id !== 'undefined') {
|
if (typeof input.id !== 'undefined') {
|
||||||
lines.push(`id: ${String(input.id)}`);
|
lines.push(`id: ${String(input.id)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../operations/contracts';
|
} from '../api-contracts';
|
||||||
|
|
||||||
export function isTerminalStatus(status: WorkerJobState): boolean {
|
export function isTerminalStatus(status: WorkerJobState): boolean {
|
||||||
return status === 'succeeded' || status === 'failed';
|
return status === 'succeeded' || status === 'failed';
|
||||||
|
|
@ -2,7 +2,7 @@ import type {
|
||||||
WorkerOperationEvent,
|
WorkerOperationEvent,
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../operations/contracts';
|
} from '../api-contracts';
|
||||||
|
|
||||||
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
|
export type OperationState<Result = unknown> = WorkerOperationState<Result>;
|
||||||
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
|
export type OperationEvent<Result = unknown> = WorkerOperationEvent<Result>;
|
||||||
24
compute/core/src/index.ts
Normal file
24
compute/core/src/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export * from './api-contracts';
|
||||||
|
export {
|
||||||
|
getComputeJobConcurrency,
|
||||||
|
getAvailableCpuCores,
|
||||||
|
getOnnxThreadsPerJob,
|
||||||
|
} from './config/cpu-budget';
|
||||||
|
export {
|
||||||
|
getComputeTimeoutConfig,
|
||||||
|
getComputeOpStaleMs,
|
||||||
|
getWorkerClientWaitTimeoutMs,
|
||||||
|
withTimeout,
|
||||||
|
withIdleTimeoutAndHardCap,
|
||||||
|
type ComputeTimeoutConfig,
|
||||||
|
type ComputeOperationKind,
|
||||||
|
type IdleTimeoutAndHardCapInput,
|
||||||
|
} from './config/timeout';
|
||||||
|
export { renderPage } from './pdf/render';
|
||||||
|
export { mergeTextWithRegions } from './pdf/merge';
|
||||||
|
export { stitchCrossPageBlocks } from './pdf/stitch';
|
||||||
|
export { normalizeTextItemsForLayout } from './pdf/normalize-text';
|
||||||
|
export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map';
|
||||||
|
export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral';
|
||||||
|
export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps';
|
||||||
|
export * from './control-plane';
|
||||||
|
|
@ -1,18 +1,4 @@
|
||||||
import type { ParsedPdfBlockKind } from '../../api/types';
|
import type { LayoutRegion, PdfTextItem } from './types';
|
||||||
|
|
||||||
export interface PdfTextItem {
|
|
||||||
text: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LayoutRegion {
|
|
||||||
bbox: [number, number, number, number];
|
|
||||||
label: ParsedPdfBlockKind;
|
|
||||||
confidence?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
|
const NON_TEXT_REGION_LABELS = new Set<LayoutRegion['label']>(['chart', 'image', 'table', 'seal']);
|
||||||
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([
|
const TEXT_ASSIGNABLE_LABELS = new Set<LayoutRegion['label']>([
|
||||||
|
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
|
import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises';
|
||||||
import { DOCSTORE_DIR } from '../../infrastructure/platform';
|
import { DOCSTORE_DIR } from '../platform/docstore';
|
||||||
|
|
||||||
const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main';
|
const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main';
|
||||||
const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL';
|
const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL';
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import type { PdfTextItem } from './document-layout';
|
import type { PdfTextItem } from './types';
|
||||||
|
|
||||||
interface ViewportLike {
|
interface ViewportLike {
|
||||||
height: number;
|
height: number;
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import type { ParsedPdfDocument, ParsedPdfPage } from '../../api/types';
|
import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf';
|
||||||
import { ensureModel } from './model';
|
import { ensureModel } from './model';
|
||||||
import { runLayoutModel } from './layout-model';
|
import { runLayoutModel } from './runLayoutModel';
|
||||||
import { mergeTextWithRegions } from './document-layout';
|
import { mergeTextWithRegions } from './merge';
|
||||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
|
import { resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
|
||||||
import { PDF_PARSER_VERSION } from '../../operations/contracts';
|
|
||||||
import { stitchCrossPageBlocks } from './stitch';
|
import { stitchCrossPageBlocks } from './stitch';
|
||||||
import { renderPage } from './render';
|
import { renderPage } from './render';
|
||||||
import { normalizeTextItemsForLayout } from './normalize-text';
|
import { normalizeTextItemsForLayout } from './normalize-text';
|
||||||
|
|
@ -36,7 +35,10 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||||
configurePdfjsNodeRuntime(pdfjs);
|
if (pdfjs.GlobalWorkerOptions) {
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||||
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
||||||
|
}
|
||||||
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
|
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
|
||||||
|
|
||||||
const loadingTask = pdfjs.getDocument({
|
const loadingTask = pdfjs.getDocument({
|
||||||
|
|
@ -141,7 +143,7 @@ export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument>
|
||||||
const doc: ParsedPdfDocument = {
|
const doc: ParsedPdfDocument = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
documentId: input.documentId,
|
documentId: input.documentId,
|
||||||
parserVersion: PDF_PARSER_VERSION,
|
parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69',
|
||||||
parsedAt: Date.now(),
|
parsedAt: Date.now(),
|
||||||
pages,
|
pages,
|
||||||
};
|
};
|
||||||
17
compute/core/src/pdf/pdfjs-runtime.ts
Normal file
17
compute/core/src/pdf/pdfjs-runtime.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
let cachedStandardFontDataUrl: string | null = null;
|
||||||
|
|
||||||
|
export function resolvePdfjsStandardFontDataUrl(): string {
|
||||||
|
if (cachedStandardFontDataUrl) return cachedStandardFontDataUrl;
|
||||||
|
|
||||||
|
const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts');
|
||||||
|
|
||||||
|
if (!fs.existsSync(standardFontDir)) {
|
||||||
|
throw new Error(`pdfjs-dist standard_fonts directory not found at ${standardFontDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedStandardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`;
|
||||||
|
return cachedStandardFontDataUrl;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Canvas } from '@napi-rs/canvas';
|
import type { Canvas } from '@napi-rs/canvas';
|
||||||
import { configurePdfjsNodeRuntime, resolvePdfjsStandardFontDataUrl } from './pdfjs';
|
import { resolvePdfjsStandardFontDataUrl } from './pdfjs-runtime';
|
||||||
|
|
||||||
type CanvasRuntime = {
|
type CanvasRuntime = {
|
||||||
DOMMatrixCtor: unknown;
|
DOMMatrixCtor: unknown;
|
||||||
|
|
@ -106,7 +106,11 @@ export async function renderPage({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
||||||
configurePdfjsNodeRuntime(pdfjs);
|
|
||||||
|
if (pdfjs.GlobalWorkerOptions) {
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||||
|
pdfjs.GlobalWorkerOptions.workerPort = null;
|
||||||
|
}
|
||||||
|
|
||||||
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
|
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
|
||||||
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import * as ort from 'onnxruntime-node';
|
import * as ort from 'onnxruntime-node';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import type { LayoutRegion, PdfTextItem } from './document-layout';
|
import type { LayoutRegion, PdfTextItem } from './types';
|
||||||
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model';
|
import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model';
|
||||||
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
|
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
|
||||||
|
|
||||||
interface RunLayoutInput {
|
interface RunLayoutInput {
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ParsedPdfDocument, ParsedPdfBlock } from '../../api/types';
|
import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf';
|
||||||
|
|
||||||
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
|
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
|
||||||
'text',
|
'text',
|
||||||
15
compute/core/src/pdf/types.ts
Normal file
15
compute/core/src/pdf/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { ParsedPdfBlockKind } from '../types/parsed-pdf';
|
||||||
|
|
||||||
|
export interface PdfTextItem {
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutRegion {
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
label: ParsedPdfBlockKind;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
21
compute/core/src/platform/docstore.ts
Normal file
21
compute/core/src/platform/docstore.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
function findMonorepoRoot(startDir: string): string | null {
|
||||||
|
let current = path.resolve(startDir);
|
||||||
|
for (;;) {
|
||||||
|
const marker = path.join(current, 'pnpm-workspace.yaml');
|
||||||
|
if (fs.existsSync(marker)) return current;
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) return null;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDocstoreDir(): string {
|
||||||
|
const repoRoot = findMonorepoRoot(process.cwd());
|
||||||
|
if (repoRoot) return path.join(repoRoot, 'docstore');
|
||||||
|
return path.join(process.cwd(), 'docstore');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCSTORE_DIR = resolveDocstoreDir();
|
||||||
36
compute/core/src/platform/ffmpeg.ts
Normal file
36
compute/core/src/platform/ffmpeg.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import ffmpegStatic from 'ffmpeg-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',
|
||||||
|
);
|
||||||
|
}
|
||||||
17
compute/core/src/types/index.ts
Normal file
17
compute/core/src/types/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
export type {
|
||||||
|
TTSAudioBuffer,
|
||||||
|
TTSAudioBytes,
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSSentenceWord,
|
||||||
|
} from './tts';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ParsedPdfBlock,
|
||||||
|
ParsedPdfBlockFragment,
|
||||||
|
ParsedPdfBlockKind,
|
||||||
|
ParsedPdfDocument,
|
||||||
|
ParsedPdfPage,
|
||||||
|
PdfParsePhase,
|
||||||
|
PdfParseProgress,
|
||||||
|
PdfParseStatus,
|
||||||
|
} from './parsed-pdf';
|
||||||
|
|
@ -61,20 +61,3 @@ export interface PdfParseProgress {
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
phase: PdfParsePhase;
|
phase: PdfParsePhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TTSAudioBuffer = ArrayBuffer;
|
|
||||||
export type TTSAudioBytes = number[];
|
|
||||||
|
|
||||||
export interface TTSSentenceWord {
|
|
||||||
text: string;
|
|
||||||
startSec: number;
|
|
||||||
endSec: number;
|
|
||||||
charStart: number;
|
|
||||||
charEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TTSSentenceAlignment {
|
|
||||||
sentence: string;
|
|
||||||
sentenceIndex: number;
|
|
||||||
words: TTSSentenceWord[];
|
|
||||||
}
|
|
||||||
16
compute/core/src/types/tts.ts
Normal file
16
compute/core/src/types/tts.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type TTSAudioBuffer = ArrayBuffer;
|
||||||
|
export type TTSAudioBytes = number[];
|
||||||
|
|
||||||
|
export interface TTSSentenceWord {
|
||||||
|
text: string;
|
||||||
|
startSec: number;
|
||||||
|
endSec: number;
|
||||||
|
charStart: number;
|
||||||
|
charEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSSentenceAlignment {
|
||||||
|
sentence: string;
|
||||||
|
sentenceIndex: number;
|
||||||
|
words: TTSSentenceWord[];
|
||||||
|
}
|
||||||
|
|
@ -7,18 +7,19 @@ import { fileURLToPath } from 'url';
|
||||||
import * as ort from 'onnxruntime-node';
|
import * as ort from 'onnxruntime-node';
|
||||||
import { Tokenizer } from '@huggingface/tokenizers';
|
import { Tokenizer } from '@huggingface/tokenizers';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../../api/types';
|
import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts';
|
||||||
import { getFFmpegPath } from '../../infrastructure/platform';
|
import { getFFmpegPath } from '../platform/ffmpeg';
|
||||||
import { getOnnxThreadsPerJob } from '../../infrastructure/config';
|
import { getOnnxThreadsPerJob } from '../config/cpu-budget';
|
||||||
import { getComputeTimeoutConfig } from '../../infrastructure/config';
|
import { getComputeTimeoutConfig } from '../config/timeout';
|
||||||
import {
|
import {
|
||||||
mapWordsToSentenceOffsets,
|
mapWordsToSentenceOffsets,
|
||||||
type WhisperWord,
|
type WhisperWord,
|
||||||
} from './timestamps';
|
} from './alignment-map';
|
||||||
|
import { buildGoertzelCoefficients, goertzelPower } from './spectral';
|
||||||
import {
|
import {
|
||||||
buildWordsFromTimestampedTokens,
|
buildWordsFromTimestampedTokens,
|
||||||
extractTokenStartTimestamps,
|
extractTokenStartTimestamps,
|
||||||
} from './timestamps';
|
} from './token-timestamps';
|
||||||
import {
|
import {
|
||||||
ensureWhisperModel,
|
ensureWhisperModel,
|
||||||
WHISPER_CONFIG_PATH,
|
WHISPER_CONFIG_PATH,
|
||||||
|
|
@ -29,31 +30,6 @@ import {
|
||||||
WHISPER_DECODER_MERGED_MODEL_PATH,
|
WHISPER_DECODER_MERGED_MODEL_PATH,
|
||||||
WHISPER_DECODER_WITH_PAST_MODEL_PATH,
|
WHISPER_DECODER_WITH_PAST_MODEL_PATH,
|
||||||
} from './model';
|
} from './model';
|
||||||
import {
|
|
||||||
applyTokenSuppression,
|
|
||||||
applyWhisperTimestampLogitsRules,
|
|
||||||
argmax,
|
|
||||||
} from './decoder';
|
|
||||||
|
|
||||||
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
|
|
||||||
const coeffs = new Float64Array(freqBins);
|
|
||||||
for (let k = 0; k < freqBins; k += 1) {
|
|
||||||
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
|
|
||||||
}
|
|
||||||
return coeffs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function goertzelPower(samples: Float32Array, coeff: number): number {
|
|
||||||
let s1 = 0;
|
|
||||||
let s2 = 0;
|
|
||||||
for (let i = 0; i < samples.length; i += 1) {
|
|
||||||
const s0 = samples[i] + (coeff * s1) - s2;
|
|
||||||
s2 = s1;
|
|
||||||
s1 = s0;
|
|
||||||
}
|
|
||||||
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
|
|
||||||
return !Number.isFinite(power) || power < 0 ? 0 : power;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WhisperAlignmentOptions {
|
interface WhisperAlignmentOptions {
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
|
@ -453,6 +429,118 @@ function buildEmptyPastFeeds() {
|
||||||
return state.emptyPastFeedsTemplate;
|
return state.emptyPastFeedsTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function argmax(values: Float32Array): number | null {
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let i = 0; i < values.length; i += 1) {
|
||||||
|
const score = values[i];
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number.isFinite(bestScore) ? bestIdx : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTokenSuppression(logits: Float32Array, tokens: Set<number>) {
|
||||||
|
for (const tokenId of tokens) {
|
||||||
|
if (tokenId >= 0 && tokenId < logits.length) {
|
||||||
|
logits[tokenId] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSoftmax(input: Float32Array): Float32Array {
|
||||||
|
let max = Number.NEGATIVE_INFINITY;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
if (input[i] > max) max = input[i];
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(max)) {
|
||||||
|
return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
sum += Math.exp(input[i] - max);
|
||||||
|
}
|
||||||
|
const logSum = Math.log(sum);
|
||||||
|
|
||||||
|
const out = new Float32Array(input.length);
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
out[i] = input[i] - max - logSum;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWhisperTimestampLogitsRules(input: {
|
||||||
|
logits: Float32Array;
|
||||||
|
generated: number[];
|
||||||
|
beginIndex: number;
|
||||||
|
eosTokenId: number;
|
||||||
|
noTimestampsTokenId: number;
|
||||||
|
timestampBeginTokenId: number;
|
||||||
|
maxInitialTimestampIndex: number;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
logits,
|
||||||
|
generated,
|
||||||
|
beginIndex,
|
||||||
|
eosTokenId,
|
||||||
|
noTimestampsTokenId,
|
||||||
|
timestampBeginTokenId,
|
||||||
|
maxInitialTimestampIndex,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) {
|
||||||
|
logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generated.length === beginIndex) {
|
||||||
|
const upper = Math.min(timestampBeginTokenId, logits.length);
|
||||||
|
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = generated.slice(beginIndex);
|
||||||
|
const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId;
|
||||||
|
const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId;
|
||||||
|
|
||||||
|
if (lastWasTimestamp) {
|
||||||
|
if (penultimateWasTimestamp) {
|
||||||
|
for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||||
|
} else {
|
||||||
|
const upper = Math.min(eosTokenId, logits.length);
|
||||||
|
for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) {
|
||||||
|
const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex;
|
||||||
|
for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textUpper = Math.min(timestampBeginTokenId, logits.length);
|
||||||
|
if (textUpper <= 0 || textUpper >= logits.length) return;
|
||||||
|
|
||||||
|
const logprobs = logSoftmax(logits);
|
||||||
|
|
||||||
|
let maxTextTokenLogprob = Number.NEGATIVE_INFINITY;
|
||||||
|
for (let i = 0; i < textUpper; i += 1) {
|
||||||
|
if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestampProbMass = 0;
|
||||||
|
for (let i = textUpper; i < logprobs.length; i += 1) {
|
||||||
|
timestampProbMass += Math.exp(logprobs[i]);
|
||||||
|
}
|
||||||
|
const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
if (timestampLogprob > maxTextTokenLogprob) {
|
||||||
|
for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getRuntime(): Promise<WhisperRuntime> {
|
async function getRuntime(): Promise<WhisperRuntime> {
|
||||||
if (state.runtimePromise) return state.runtimePromise;
|
if (state.runtimePromise) return state.runtimePromise;
|
||||||
|
|
||||||
54
compute/core/src/whisper/alignment-map.ts
Normal file
54
compute/core/src/whisper/alignment-map.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts';
|
||||||
|
|
||||||
|
function preprocessSentenceForAudio(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
|
||||||
|
.replace(/(\w+)-\s+(\w+)/g, '$1$2')
|
||||||
|
.replace(/\*/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhisperWord {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
word: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
|
||||||
|
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||||||
|
const lowerSentence = normalizedSentence.toLowerCase();
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
const alignedWords: TTSSentenceWord[] = words.map((w) => {
|
||||||
|
const token = w.word.trim();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
startSec: w.start,
|
||||||
|
endSec: w.end,
|
||||||
|
charStart: cursor,
|
||||||
|
charEnd: cursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = lowerSentence.indexOf(token.toLowerCase(), cursor);
|
||||||
|
const start = idx >= 0 ? idx : cursor;
|
||||||
|
const end = Math.min(normalizedSentence.length, start + token.length);
|
||||||
|
cursor = Math.max(cursor, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: token,
|
||||||
|
startSec: w.start,
|
||||||
|
endSec: w.end,
|
||||||
|
charStart: start,
|
||||||
|
charEnd: end,
|
||||||
|
};
|
||||||
|
}).filter((word) => word.text.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sentence,
|
||||||
|
sentenceIndex: 0,
|
||||||
|
words: alignedWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
||||||
import { DOCSTORE_DIR } from '../../infrastructure/platform';
|
import { DOCSTORE_DIR } from '../platform/docstore';
|
||||||
|
|
||||||
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped');
|
const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped');
|
||||||
21
compute/core/src/whisper/spectral.ts
Normal file
21
compute/core/src/whisper/spectral.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array {
|
||||||
|
const coeffs = new Float64Array(freqBins);
|
||||||
|
for (let k = 0; k < freqBins; k += 1) {
|
||||||
|
coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize);
|
||||||
|
}
|
||||||
|
return coeffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function goertzelPower(samples: Float32Array, coeff: number): number {
|
||||||
|
let s1 = 0;
|
||||||
|
let s2 = 0;
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
const s0 = samples[i] + (coeff * s1) - s2;
|
||||||
|
s2 = s1;
|
||||||
|
s1 = s0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2);
|
||||||
|
if (!Number.isFinite(power) || power < 0) return 0;
|
||||||
|
return power;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { Tokenizer } from '@huggingface/tokenizers';
|
import type { Tokenizer } from '@huggingface/tokenizers';
|
||||||
import type * as ort from 'onnxruntime-node';
|
import type * as ort from 'onnxruntime-node';
|
||||||
import type { TTSSentenceAlignment, TTSSentenceWord } from '../../api/types';
|
|
||||||
|
|
||||||
const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E';
|
const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E';
|
||||||
const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu');
|
const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu');
|
||||||
|
|
@ -13,29 +12,6 @@ export interface WhisperWordTiming {
|
||||||
endSec: number;
|
endSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhisperWord {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
word: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment {
|
|
||||||
const lowerSentence = sentence.toLowerCase();
|
|
||||||
let cursor = 0;
|
|
||||||
const alignedWords: TTSSentenceWord[] = words.map((word) => {
|
|
||||||
const token = word.word.trim();
|
|
||||||
if (!token) {
|
|
||||||
return { text: '', startSec: word.start, endSec: word.end, charStart: cursor, charEnd: cursor };
|
|
||||||
}
|
|
||||||
const index = lowerSentence.indexOf(token.toLowerCase(), cursor);
|
|
||||||
const start = index >= 0 ? index : cursor;
|
|
||||||
const end = Math.min(sentence.length, start + token.length);
|
|
||||||
cursor = Math.max(cursor, end);
|
|
||||||
return { text: token, startSec: word.start, endSec: word.end, charStart: start, charEnd: end };
|
|
||||||
}).filter((word) => word.text.length > 0);
|
|
||||||
return { sentence, sentenceIndex: 0, words: alignedWords };
|
|
||||||
}
|
|
||||||
|
|
||||||
function medianFilter(data: Float32Array, windowSize: number): Float32Array {
|
function medianFilter(data: Float32Array, windowSize: number): Float32Array {
|
||||||
if (windowSize % 2 === 0 || windowSize <= 0) {
|
if (windowSize % 2 === 0 || windowSize <= 0) {
|
||||||
throw new Error('Window size must be a positive odd number');
|
throw new Error('Window size must be a positive odd number');
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import type { WorkerOperationRequest } from '../../../src/operations/contracts';
|
import type { WorkerOperationRequest } from '../../src/api-contracts';
|
||||||
import { OperationOrchestrator } from '../../../src/operations';
|
import { OperationOrchestrator } from '../../src/control-plane';
|
||||||
import {
|
import {
|
||||||
InMemoryOperationEventStream,
|
InMemoryOperationEventStream,
|
||||||
InMemoryOperationQueue,
|
InMemoryOperationQueue,
|
||||||
|
|
@ -70,13 +70,13 @@ vi.mock('@napi-rs/canvas', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('../../../src/inference/pdf/model', () => ({
|
vi.mock('../../src/pdf/model', () => ({
|
||||||
ensureModel: vi.fn(async () => '/tmp/model.onnx'),
|
ensureModel: vi.fn(async () => '/tmp/model.onnx'),
|
||||||
MODEL_CONFIG_PATH: '/tmp/model-config.json',
|
MODEL_CONFIG_PATH: '/tmp/model-config.json',
|
||||||
MODEL_PREPROCESSOR_PATH: '/tmp/model-preprocessor.json',
|
MODEL_PREPROCESSOR_PATH: '/tmp/model-preprocessor.json',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../src/infrastructure/config', () => ({
|
vi.mock('../../src/config/cpu-budget', () => ({
|
||||||
getOnnxThreadsPerJob: vi.fn(() => 1),
|
getOnnxThreadsPerJob: vi.fn(() => 1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ describe('runLayoutModel', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { runLayoutModel } = await import('../../../src/inference/pdf/layout-model');
|
const { runLayoutModel } = await import('../../src/pdf/runLayoutModel');
|
||||||
const regions = await runLayoutModel({
|
const regions = await runLayoutModel({
|
||||||
pageWidth: 100,
|
pageWidth: 100,
|
||||||
pageHeight: 100,
|
pageHeight: 100,
|
||||||
|
|
@ -154,7 +154,7 @@ describe('runLayoutModel', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { runLayoutModel } = await import('../../../src/inference/pdf/layout-model');
|
const { runLayoutModel } = await import('../../src/pdf/runLayoutModel');
|
||||||
const regions = await runLayoutModel({
|
const regions = await runLayoutModel({
|
||||||
pageWidth: 100,
|
pageWidth: 100,
|
||||||
pageHeight: 100,
|
pageHeight: 100,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { encodeSseFrame, parseSseEventId, parseSsePayload } from '../../../src/operations/sse';
|
import { encodeSseFrame, parseSseEventId, parseSsePayload } from '../../src/control-plane/sse';
|
||||||
|
|
||||||
describe('sse codec', () => {
|
describe('sse codec', () => {
|
||||||
test('encodes event id and payload and decodes both reliably', () => {
|
test('encodes event id and payload and decodes both reliably', () => {
|
||||||
|
|
@ -23,14 +23,4 @@ describe('sse codec', () => {
|
||||||
expect(parseSseEventId(frame)).toBe(5);
|
expect(parseSseEventId(frame)).toBe(5);
|
||||||
expect(parseSsePayload(frame)).toBe('line1\nline2');
|
expect(parseSsePayload(frame)).toBe('line1\nline2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('emits a retry directive when provided', () => {
|
|
||||||
const frame = encodeSseFrame({ retry: 120_000 });
|
|
||||||
expect(frame).toContain('retry: 120000');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits retry when not finite and floors fractional values', () => {
|
|
||||||
expect(encodeSseFrame({ retry: Number.NaN })).not.toContain('retry:');
|
|
||||||
expect(encodeSseFrame({ retry: 1500.9 })).toContain('retry: 1500');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import type { WorkerOperationState } from '../../../src/operations/contracts';
|
import type { WorkerOperationState } from '../../src/api-contracts';
|
||||||
import {
|
import {
|
||||||
explainReplacementReason,
|
explainReplacementReason,
|
||||||
isInflightStatus,
|
isInflightStatus,
|
||||||
isTerminalStatus,
|
isTerminalStatus,
|
||||||
shouldReuseExistingOperation,
|
shouldReuseExistingOperation,
|
||||||
} from '../../../src/operations/state-machine';
|
} from '../../src/control-plane/state-machine';
|
||||||
|
|
||||||
function runningState(overrides: Partial<WorkerOperationState> = {}): WorkerOperationState {
|
function runningState(overrides: Partial<WorkerOperationState> = {}): WorkerOperationState {
|
||||||
return {
|
return {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { WorkerOperationKind } from '../../../src/operations/contracts';
|
import type { WorkerOperationKind } from '../../src/api-contracts';
|
||||||
import type {
|
import type {
|
||||||
OperationEvent,
|
OperationEvent,
|
||||||
OperationEventStream,
|
OperationEventStream,
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
OperationState,
|
OperationState,
|
||||||
OperationStateStore,
|
OperationStateStore,
|
||||||
QueuedOperation,
|
QueuedOperation,
|
||||||
} from '../../../src/operations/types';
|
} from '../../src/control-plane/types';
|
||||||
|
|
||||||
function topicFor(opId: string): string {
|
function topicFor(opId: string): string {
|
||||||
return `op.${opId}`;
|
return `op.${opId}`;
|
||||||
7
compute/core/tsconfig.json
Normal file
7
compute/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
FROM node:lts AS deploy-stage
|
FROM node:lts AS deploy-stage
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.33.4
|
RUN npm install -g pnpm@11.1.2
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY packages/compute-worker/package.json packages/compute-worker/package.json
|
COPY compute/worker/package.json compute/worker/package.json
|
||||||
COPY packages/compute-worker packages/compute-worker
|
COPY compute/core/package.json compute/core/package.json
|
||||||
|
|
||||||
|
COPY compute/worker compute/worker
|
||||||
|
COPY compute/core compute/core
|
||||||
|
|
||||||
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
|
RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker
|
||||||
|
|
||||||
FROM node:lts
|
FROM node:lts
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@11.1.2
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY --from=deploy-stage /opt/compute-worker ./
|
COPY --from=deploy-stage /opt/compute-worker ./
|
||||||
47
compute/worker/docker-compose.yml
Normal file
47
compute/worker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
services:
|
||||||
|
nats:
|
||||||
|
image: nats:2.14-alpine
|
||||||
|
container_name: openreader-compute-nats
|
||||||
|
command: ["-js", "-sd", "/data"]
|
||||||
|
ports:
|
||||||
|
- "4222:4222"
|
||||||
|
- "8222:8222"
|
||||||
|
volumes:
|
||||||
|
- nats-data:/data
|
||||||
|
|
||||||
|
compute-worker:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: compute/worker/Dockerfile
|
||||||
|
container_name: openreader-compute-worker
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
environment:
|
||||||
|
NATS_URL: ${NATS_URL:-nats://nats:4222}
|
||||||
|
COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0}
|
||||||
|
PORT: ${PORT:-8081}
|
||||||
|
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
||||||
|
S3_PREFIX: ${S3_PREFIX:-openreader}
|
||||||
|
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync+restart
|
||||||
|
path: .
|
||||||
|
target: /workspace
|
||||||
|
- action: rebuild
|
||||||
|
path: ../../compute/core
|
||||||
|
- action: rebuild
|
||||||
|
path: ./package.json
|
||||||
|
- action: rebuild
|
||||||
|
path: ../../compute/core/package.json
|
||||||
|
- action: rebuild
|
||||||
|
path: ../../pnpm-lock.yaml
|
||||||
|
- action: rebuild
|
||||||
|
path: ../../pnpm-workspace.yaml
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nats-data:
|
||||||
|
|
@ -5,22 +5,15 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"start": "tsx src/server.ts",
|
"start": "tsx src/server.ts"
|
||||||
"openapi:generate": "tsx scripts/generate-openapi.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1061.0",
|
"@aws-sdk/client-s3": "^3.1061.0",
|
||||||
"@fastify/swagger": "^9.0.0",
|
|
||||||
"@huggingface/tokenizers": "^0.1.3",
|
|
||||||
"@napi-rs/canvas": "^0.1.100",
|
|
||||||
"@nats-io/jetstream": "^3.4.0",
|
"@nats-io/jetstream": "^3.4.0",
|
||||||
"@nats-io/kv": "^3.4.0",
|
"@nats-io/kv": "^3.4.0",
|
||||||
"@nats-io/transport-node": "^3.4.0",
|
"@nats-io/transport-node": "^3.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"@openreader/compute-core": "workspace:*",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"onnxruntime-node": "^1.26.0",
|
|
||||||
"pdfjs-dist": "4.8.69",
|
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
|
|
@ -8,12 +8,12 @@ import type {
|
||||||
OperationState,
|
OperationState,
|
||||||
OperationStateStore,
|
OperationStateStore,
|
||||||
QueuedOperation,
|
QueuedOperation,
|
||||||
} from '../operations';
|
} from '@openreader/compute-core/control-plane';
|
||||||
import type {
|
import type {
|
||||||
PdfLayoutJobRequest,
|
PdfLayoutJobRequest,
|
||||||
WhisperAlignJobRequest,
|
WhisperAlignJobRequest,
|
||||||
WorkerOperationKind,
|
WorkerOperationKind,
|
||||||
} from '../operations/contracts';
|
} from '@openreader/compute-core/api-contracts';
|
||||||
import { createJsonCodec } from './json-codec';
|
import { createJsonCodec } from './json-codec';
|
||||||
|
|
||||||
export interface KvEntryLike {
|
export interface KvEntryLike {
|
||||||
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
WorkerJobTiming,
|
WorkerJobTiming,
|
||||||
WorkerJobState,
|
WorkerJobState,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../operations/contracts';
|
} from '@openreader/compute-core/api-contracts';
|
||||||
|
|
||||||
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
export type StreamedOperationState = WorkerOperationState<WhisperAlignJobResult | PdfLayoutJobResult>;
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { PdfLayoutProgress } from '../operations/contracts';
|
import type { PdfLayoutProgress } from '@openreader/compute-core/api-contracts';
|
||||||
|
|
||||||
export function buildInferProgressForPageStart(input: {
|
export function buildInferProgressForPageStart(input: {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
1545
compute/worker/src/runtime.ts
Normal file
1545
compute/worker/src/runtime.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,4 @@
|
||||||
import { startComputeWorkerFromEnv } from './api/app';
|
import { startComputeWorkerFromEnv } from './runtime';
|
||||||
|
|
||||||
void startComputeWorkerFromEnv().catch((error) => {
|
void startComputeWorkerFromEnv().catch((error) => {
|
||||||
console.error('[compute-worker] fatal startup error', error);
|
console.error('[compute-worker] fatal startup error', error);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { WorkerOperationKind } from '../operations/contracts';
|
import type { WorkerOperationKind } from '@openreader/compute-core/api-contracts';
|
||||||
|
|
||||||
export type RetryAction = 'nak_retry' | 'term_fail';
|
export type RetryAction = 'nak_retry' | 'term_fail';
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { createComputeWorkerApp } from '../../src/api/app';
|
import { createComputeWorkerApp } from '../../src/runtime';
|
||||||
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||||
|
|
||||||
const AUTH = { authorization: 'Bearer test-token' };
|
const AUTH = { authorization: 'Bearer test-token' };
|
||||||
|
|
@ -25,51 +25,53 @@ describe('compute worker API routes', () => {
|
||||||
const live = await runtime.app.inject({ method: 'GET', url: '/health/live' });
|
const live = await runtime.app.inject({ method: 'GET', url: '/health/live' });
|
||||||
expect(live.statusCode).toBe(200);
|
expect(live.statusCode).toBe(200);
|
||||||
|
|
||||||
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/v1/operations/op-1' });
|
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/ops/op-1' });
|
||||||
expect(protectedRoute.statusCode).toBe(401);
|
expect(protectedRoute.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates operation creation body and returns 400 for invalid payload', async () => {
|
test('validates operation creation body and returns 400 for invalid payload', async () => {
|
||||||
const response = await runtime.app.inject({
|
const response = await runtime.app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/v1/pdf-layout/operations',
|
url: '/ops',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
payload: {
|
payload: {
|
||||||
documentId: '',
|
kind: 'pdf_layout',
|
||||||
namespace: null,
|
opKey: '',
|
||||||
documentObjectKey: 'openreader/doc.pdf',
|
payload: {
|
||||||
|
documentId: 'd1',
|
||||||
|
namespace: null,
|
||||||
|
documentObjectKey: 's3://bucket/doc.pdf',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect(response.json()).toMatchObject({ error: 'Bad Request' });
|
expect(response.json()).toMatchObject({ error: 'Invalid request body' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates operation and fetches state by op id', async () => {
|
test('creates operation and fetches state by op id', async () => {
|
||||||
const documentId = 'c'.repeat(64);
|
|
||||||
const create = await runtime.app.inject({
|
const create = await runtime.app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/v1/pdf-layout/operations',
|
url: '/ops',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
payload: {
|
payload: {
|
||||||
documentId,
|
kind: 'pdf_layout',
|
||||||
namespace: null,
|
opKey: 'doc-1:layout',
|
||||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
payload: {
|
||||||
|
documentId: 'doc-1',
|
||||||
|
namespace: null,
|
||||||
|
documentObjectKey: 'openreader/doc-1.pdf',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(create.statusCode).toBe(202);
|
expect(create.statusCode).toBe(202);
|
||||||
const created = create.json();
|
const created = create.json();
|
||||||
expect(created).toMatchObject({
|
expect(created).toMatchObject({ kind: 'pdf_layout', status: 'queued' });
|
||||||
subject: { kind: 'pdf_layout', documentId, namespace: null },
|
|
||||||
status: 'queued',
|
|
||||||
});
|
|
||||||
expect(created).not.toHaveProperty('opKey');
|
|
||||||
expect(created).not.toHaveProperty('jobId');
|
|
||||||
|
|
||||||
const fetch = await runtime.app.inject({
|
const fetch = await runtime.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/v1/operations/${created.opId}`,
|
url: `/ops/${created.opId}`,
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -77,120 +79,17 @@ describe('compute worker API routes', () => {
|
||||||
expect(fetch.json()).toMatchObject({ opId: created.opId, status: 'queued' });
|
expect(fetch.json()).toMatchObject({ opId: created.opId, status: 'queued' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reuses idempotent PDF requests and replaces them only with an explicit token', async () => {
|
|
||||||
const documentId = 'e'.repeat(64);
|
|
||||||
const payload = {
|
|
||||||
documentId,
|
|
||||||
namespace: null,
|
|
||||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
|
||||||
};
|
|
||||||
const create = (body: typeof payload & { replaceToken?: string }) => runtime.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/pdf-layout/operations',
|
|
||||||
headers: AUTH,
|
|
||||||
payload: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const initial = (await create(payload)).json();
|
|
||||||
const reused = (await create(payload)).json();
|
|
||||||
const replacement = (await create({ ...payload, replaceToken: 'replace-1' })).json();
|
|
||||||
|
|
||||||
expect(reused.opId).toBe(initial.opId);
|
|
||||||
expect(replacement.opId).not.toBe(initial.opId);
|
|
||||||
expect(replacement.subject).toEqual(initial.subject);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates whisper alignment operations without exposing internal keys', async () => {
|
|
||||||
const response = await runtime.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/whisper-align/operations',
|
|
||||||
headers: AUTH,
|
|
||||||
payload: {
|
|
||||||
text: 'Canonical worker text',
|
|
||||||
audioObjectKey: 'openreader/audio.mp3',
|
|
||||||
lang: 'en',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.statusCode).toBe(202);
|
|
||||||
expect(response.json()).toMatchObject({
|
|
||||||
subject: { kind: 'whisper_align' },
|
|
||||||
status: 'queued',
|
|
||||||
});
|
|
||||||
expect(response.json()).not.toHaveProperty('opKey');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves the current PDF artifact and operation without exposing parser identity', async () => {
|
|
||||||
const documentId = 'a'.repeat(64);
|
|
||||||
const create = await runtime.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/pdf-layout/operations',
|
|
||||||
headers: AUTH,
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
namespace: null,
|
|
||||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(create.statusCode).toBe(202);
|
|
||||||
|
|
||||||
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
|
|
||||||
fake.seedArtifact(artifactKey);
|
|
||||||
const resolve = await runtime.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/pdf-layout/resolve',
|
|
||||||
headers: AUTH,
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
namespace: null,
|
|
||||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolve.statusCode).toBe(200);
|
|
||||||
expect(resolve.json()).toMatchObject({
|
|
||||||
artifact: { objectKey: artifactKey },
|
|
||||||
operation: {
|
|
||||||
subject: { kind: 'pdf_layout', documentId, namespace: null },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(resolve.json().operation).not.toHaveProperty('opKey');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves a current PDF artifact after operation state has expired', async () => {
|
|
||||||
const documentId = 'd'.repeat(64);
|
|
||||||
const artifactKey = `openreader/documents_v1/parsed_v2/${documentId}/pp-doclayoutv3-onnx%40800%2Bpdfjs%404.8.69.json`;
|
|
||||||
fake.seedArtifact(artifactKey);
|
|
||||||
|
|
||||||
const resolve = await runtime.app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/v1/pdf-layout/resolve',
|
|
||||||
headers: AUTH,
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
namespace: null,
|
|
||||||
documentObjectKey: `openreader/${documentId}.pdf`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolve.statusCode).toBe(200);
|
|
||||||
expect(resolve.json()).toEqual({
|
|
||||||
artifact: { objectKey: artifactKey },
|
|
||||||
operation: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns not found for unknown operation and event stream lookups', async () => {
|
test('returns not found for unknown operation and event stream lookups', async () => {
|
||||||
const opResponse = await runtime.app.inject({
|
const opResponse = await runtime.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/operations/missing',
|
url: '/ops/missing',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
});
|
});
|
||||||
expect(opResponse.statusCode).toBe(404);
|
expect(opResponse.statusCode).toBe(404);
|
||||||
|
|
||||||
const eventsResponse = await runtime.app.inject({
|
const eventsResponse = await runtime.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/operations/missing/events',
|
url: '/ops/missing/events',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
});
|
});
|
||||||
expect(eventsResponse.statusCode).toBe(404);
|
expect(eventsResponse.statusCode).toBe(404);
|
||||||
|
|
@ -210,7 +109,7 @@ describe('compute worker API routes', () => {
|
||||||
|
|
||||||
const stream = await runtime.app.inject({
|
const stream = await runtime.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/operations/op-terminal/events?sinceEventId=7',
|
url: '/ops/op-terminal/events?sinceEventId=7',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -260,11 +159,11 @@ describe('compute worker API routes', () => {
|
||||||
updatedAt: now - 310_000,
|
updatedAt: now - 310_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /v1/operations/:opId resolves via getOpState(), which first awaits the shared
|
// GET /ops/:opId resolves via getOpState(), which first awaits the shared
|
||||||
// orphanRecoveryPromise path through ensureOrphanedOpRecovery().
|
// orphanRecoveryPromise path through ensureOrphanedOpRecovery().
|
||||||
const fetch = await runtime.app.inject({
|
const fetch = await runtime.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/operations/op-stale-whisper-running',
|
url: '/ops/op-stale-whisper-running',
|
||||||
headers: AUTH,
|
headers: AUTH,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -4,8 +4,8 @@ import type {
|
||||||
WorkerOperationEvent,
|
WorkerOperationEvent,
|
||||||
WorkerOperationRequest,
|
WorkerOperationRequest,
|
||||||
WorkerOperationState,
|
WorkerOperationState,
|
||||||
} from '../../src/operations/contracts';
|
} from '@openreader/compute-core/api-contracts';
|
||||||
import type { ComputeWorkerRouteDeps } from '../../src/api/app';
|
import type { ComputeWorkerRouteDeps } from '../../src/runtime';
|
||||||
|
|
||||||
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
|
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
|
||||||
type ComputeState = WorkerOperationState<ComputeResult>;
|
type ComputeState = WorkerOperationState<ComputeResult>;
|
||||||
|
|
@ -16,7 +16,6 @@ export class FakeControlPlane {
|
||||||
private readonly revisionByOpId = new Map<string, number>();
|
private readonly revisionByOpId = new Map<string, number>();
|
||||||
private readonly opIdByOpKey = new Map<string, string>();
|
private readonly opIdByOpKey = new Map<string, string>();
|
||||||
private readonly eventsByOpId = new Map<string, ComputeEvent[]>();
|
private readonly eventsByOpId = new Map<string, ComputeEvent[]>();
|
||||||
private readonly artifactKeys = new Set<string>();
|
|
||||||
private nextOpId = 1;
|
private nextOpId = 1;
|
||||||
|
|
||||||
readonly deps: ComputeWorkerRouteDeps = {
|
readonly deps: ComputeWorkerRouteDeps = {
|
||||||
|
|
@ -59,10 +58,6 @@ export class FakeControlPlane {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
listOpStates: async () => Array.from(this.stateByOpId.values()),
|
listOpStates: async () => Array.from(this.stateByOpId.values()),
|
||||||
getOpIndex: async (opKey) => {
|
|
||||||
const opId = this.opIdByOpKey.get(opKey);
|
|
||||||
return opId ? { opId } : null;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
operationEventStream: {
|
operationEventStream: {
|
||||||
subscribe: async ({ opId, sinceEventId, onEvent }) => {
|
subscribe: async ({ opId, sinceEventId, onEvent }) => {
|
||||||
|
|
@ -74,7 +69,6 @@ export class FakeControlPlane {
|
||||||
return () => undefined;
|
return () => undefined;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
artifactExists: async (key) => this.artifactKeys.has(key),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
seedState(state: ComputeState): void {
|
seedState(state: ComputeState): void {
|
||||||
|
|
@ -89,10 +83,6 @@ export class FakeControlPlane {
|
||||||
this.eventsByOpId.set(opId, list);
|
this.eventsByOpId.set(opId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
seedArtifact(key: string): void {
|
|
||||||
this.artifactKeys.add(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(opId: string): ComputeState | null {
|
getState(opId: string): ComputeState | null {
|
||||||
return this.stateByOpId.get(opId) ?? null;
|
return this.stateByOpId.get(opId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { OperationOrchestrator } from '../../src/operations';
|
import { OperationOrchestrator } from '@openreader/compute-core/control-plane';
|
||||||
import type { WorkerOperationRequest } from '../../src/operations/contracts';
|
import type { WorkerOperationRequest } from '@openreader/compute-core/api-contracts';
|
||||||
import {
|
import {
|
||||||
JetStreamOperationEventStream,
|
JetStreamOperationEventStream,
|
||||||
JetStreamOperationQueue,
|
JetStreamOperationQueue,
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
opStateKvKey,
|
opStateKvKey,
|
||||||
type KvEntryLike,
|
type KvEntryLike,
|
||||||
type KvStoreLike,
|
type KvStoreLike,
|
||||||
} from '../../src/infrastructure/nats-adapters';
|
} from '../../src/control-plane/jetstream';
|
||||||
|
|
||||||
class FakeKvStore implements KvStoreLike {
|
class FakeKvStore implements KvStoreLike {
|
||||||
private readonly data = new Map<string, KvEntryLike>();
|
private readonly data = new Map<string, KvEntryLike>();
|
||||||
|
|
@ -45,17 +45,6 @@ class FakeKvStore implements KvStoreLike {
|
||||||
this.revision += 1;
|
this.revision += 1;
|
||||||
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
|
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
|
||||||
}
|
}
|
||||||
|
|
||||||
async keys(filter?: string | string[]): Promise<AsyncIterable<string>> {
|
|
||||||
const keys = Array.from(this.data.keys());
|
|
||||||
return {
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
for (const key of keys) {
|
|
||||||
yield key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeJetStream {
|
class FakeJetStream {
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { hashOpKey, opIndexKvKey, opStateKvKey } from '../../src/infrastructure/nats-adapters';
|
import { hashOpKey, opIndexKvKey, opStateKvKey } from '../../src/control-plane/jetstream';
|
||||||
import {
|
import {
|
||||||
buildInferProgressForPageParsed,
|
buildInferProgressForPageParsed,
|
||||||
buildInferProgressForPageStart,
|
buildInferProgressForPageStart,
|
||||||
} from '../../src/jobs/pdf-progress';
|
} from '../../src/pdf-progress';
|
||||||
import { buildPdfOperationKey } from '../../src/operations/keys';
|
|
||||||
import { parsedPdfArtifactKey } from '../../src/storage/artifact-addressing';
|
|
||||||
|
|
||||||
describe('compute worker helpers', () => {
|
describe('compute worker helpers', () => {
|
||||||
test('hash and kv key helpers are stable and deterministic', () => {
|
test('hash and kv key helpers are stable and deterministic', () => {
|
||||||
|
|
@ -36,17 +34,4 @@ describe('compute worker helpers', () => {
|
||||||
phase: 'infer',
|
phase: 'infer',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parser-version changes rotate worker-owned operation and artifact identities', () => {
|
|
||||||
const request = {
|
|
||||||
documentId: 'a'.repeat(64),
|
|
||||||
namespace: null,
|
|
||||||
documentObjectKey: `openreader/${'a'.repeat(64)}.pdf`,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(buildPdfOperationKey(request, 'parser-v1'))
|
|
||||||
.not.toBe(buildPdfOperationKey(request, 'parser-v2'));
|
|
||||||
expect(parsedPdfArtifactKey({ ...request, prefix: 'openreader', parserVersion: 'parser-v1' }))
|
|
||||||
.not.toBe(parsedPdfArtifactKey({ ...request, prefix: 'openreader', parserVersion: 'parser-v2' }));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { recoverOrphanedOperations } from '../../src/operations/recovery';
|
import { recoverOrphanedOperations } from '../../src/orphan-recovery';
|
||||||
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
import { FakeControlPlane } from '../fixtures/fake-control-plane';
|
||||||
|
|
||||||
describe('orphan recovery', () => {
|
describe('orphan recovery', () => {
|
||||||
|
|
@ -24,8 +24,8 @@ describe('orphan recovery', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstSweep = await recoverOrphanedOperations({
|
const firstSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore!,
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
orchestrator: fake.deps.orchestrator!,
|
orchestrator: fake.deps.orchestrator,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
@ -38,8 +38,8 @@ describe('orphan recovery', () => {
|
||||||
vi.advanceTimersByTime(31_000);
|
vi.advanceTimersByTime(31_000);
|
||||||
|
|
||||||
const secondSweep = await recoverOrphanedOperations({
|
const secondSweep = await recoverOrphanedOperations({
|
||||||
operationStateStore: fake.deps.operationStateStore!,
|
operationStateStore: fake.deps.operationStateStore,
|
||||||
orchestrator: fake.deps.orchestrator!,
|
orchestrator: fake.deps.orchestrator,
|
||||||
whisperTimeoutMs: 30_000,
|
whisperTimeoutMs: 30_000,
|
||||||
pdfTimeoutMs: 300_000,
|
pdfTimeoutMs: 300_000,
|
||||||
opStaleMs: 1_800_000,
|
opStaleMs: 1_800_000,
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { buildQueueWaitTiming, decideRetryAction } from '../../src/jobs/worker-loop-policy';
|
import { buildQueueWaitTiming, decideRetryAction } from '../../src/worker-loop-policy';
|
||||||
|
|
||||||
describe('worker loop policy', () => {
|
describe('worker loop policy', () => {
|
||||||
test('returns queue wait timing with non-negative clamped duration', () => {
|
test('returns queue wait timing with non-negative clamped duration', () => {
|
||||||
14
docker/entrypoint-migration-tools/package.json
Normal file
14
docker/entrypoint-migration-tools/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "@openreader/entrypoint-migration-tools",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1061.0",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"pg": "^8.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
name: openreader-full
|
|
||||||
|
|
||||||
services:
|
|
||||||
openreader:
|
|
||||||
image: ghcr.io/richardr1126/openreader:latest
|
|
||||||
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
|
|
||||||
network_mode: service:seaweedfs
|
|
||||||
depends_on:
|
|
||||||
kokoro-tts:
|
|
||||||
condition: service_started
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
seaweedfs:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
|
||||||
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
|
|
||||||
COMPUTE_WORKER_URL: http://compute-worker:8081
|
|
||||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
|
||||||
USE_EMBEDDED_WEED_MINI: "false"
|
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
S3_FORCE_PATH_STYLE: "true"
|
|
||||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
|
||||||
RUNTIME_SEED_JSON: |-
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"runtimeConfig": {
|
|
||||||
"defaultTtsProvider": "kokoro"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"slug": "kokoro",
|
|
||||||
"displayName": "Kokoro",
|
|
||||||
"providerType": "custom-openai",
|
|
||||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
|
||||||
"defaultModel": "kokoro",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
volumes:
|
|
||||||
- openreader-docstore:/app/docstore
|
|
||||||
|
|
||||||
kokoro-tts:
|
|
||||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
|
||||||
environment:
|
|
||||||
ONNX_NUM_THREADS: 8
|
|
||||||
ONNX_INTER_OP_THREADS: 4
|
|
||||||
ONNX_EXECUTION_MODE: parallel
|
|
||||||
ONNX_OPTIMIZATION_LEVEL: all
|
|
||||||
ONNX_MEMORY_PATTERN: "true"
|
|
||||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
|
||||||
API_LOG_LEVEL: DEBUG
|
|
||||||
ports:
|
|
||||||
- "8880:8880"
|
|
||||||
|
|
||||||
seaweedfs:
|
|
||||||
image: chrislusf/seaweedfs:4.18
|
|
||||||
command: ["mini", "-dir=/data"]
|
|
||||||
environment:
|
|
||||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 30
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
- "8333:8333"
|
|
||||||
volumes:
|
|
||||||
- seaweedfs-data:/data
|
|
||||||
|
|
||||||
nats:
|
|
||||||
image: nats:2.14-alpine
|
|
||||||
command: ["-js", "-sd", "/data"]
|
|
||||||
volumes:
|
|
||||||
- nats-data:/data
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: openreader
|
|
||||||
POSTGRES_PASSWORD: openreader
|
|
||||||
POSTGRES_DB: openreader
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 30
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
compute-worker:
|
|
||||||
image: ghcr.io/richardr1126/openreader-compute-worker:latest
|
|
||||||
depends_on:
|
|
||||||
nats:
|
|
||||||
condition: service_started
|
|
||||||
seaweedfs:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NATS_URL: nats://nats:4222
|
|
||||||
COMPUTE_WORKER_HOST: 0.0.0.0
|
|
||||||
PORT: 8081
|
|
||||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
|
||||||
S3_ENDPOINT: http://seaweedfs:8333
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
S3_FORCE_PATH_STYLE: "true"
|
|
||||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
|
||||||
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openreader-docstore:
|
|
||||||
seaweedfs-data:
|
|
||||||
nats-data:
|
|
||||||
postgres-data:
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
name: openreader-local-full-build
|
|
||||||
|
|
||||||
services:
|
|
||||||
openreader:
|
|
||||||
image: openreader:local
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
# Keep localhost:8333 valid for both app requests and browser-facing presigned URLs.
|
|
||||||
network_mode: service:seaweedfs
|
|
||||||
depends_on:
|
|
||||||
kokoro-tts:
|
|
||||||
condition: service_started
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
seaweedfs:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
|
||||||
POSTGRES_URL: postgres://openreader:openreader@postgres:5432/openreader
|
|
||||||
COMPUTE_WORKER_URL: http://compute-worker:8081
|
|
||||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
|
||||||
USE_EMBEDDED_WEED_MINI: "false"
|
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:-http://localhost:8333} # Used for internal endpoint and public facing presigned URLs.
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
S3_FORCE_PATH_STYLE: "true"
|
|
||||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
|
||||||
RUNTIME_SEED_JSON: |-
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"runtimeConfig": {
|
|
||||||
"defaultTtsProvider": "kokoro"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"slug": "kokoro",
|
|
||||||
"displayName": "Kokoro",
|
|
||||||
"providerType": "custom-openai",
|
|
||||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
|
||||||
"defaultModel": "kokoro",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
volumes:
|
|
||||||
- openreader-docstore:/app/docstore
|
|
||||||
|
|
||||||
kokoro-tts:
|
|
||||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
|
||||||
environment:
|
|
||||||
ONNX_NUM_THREADS: 8
|
|
||||||
ONNX_INTER_OP_THREADS: 4
|
|
||||||
ONNX_EXECUTION_MODE: parallel
|
|
||||||
ONNX_OPTIMIZATION_LEVEL: all
|
|
||||||
ONNX_MEMORY_PATTERN: "true"
|
|
||||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
|
||||||
API_LOG_LEVEL: DEBUG
|
|
||||||
ports:
|
|
||||||
- "8880:8880"
|
|
||||||
|
|
||||||
seaweedfs:
|
|
||||||
image: chrislusf/seaweedfs:4.18
|
|
||||||
command: ["mini", "-dir=/data"]
|
|
||||||
environment:
|
|
||||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9333/cluster/status >/dev/null"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 30
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
- "8333:8333"
|
|
||||||
volumes:
|
|
||||||
- seaweedfs-data:/data
|
|
||||||
|
|
||||||
nats:
|
|
||||||
image: nats:2.14-alpine
|
|
||||||
command: ["-js", "-sd", "/data"]
|
|
||||||
volumes:
|
|
||||||
- nats-data:/data
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:17-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: openreader
|
|
||||||
POSTGRES_PASSWORD: openreader
|
|
||||||
POSTGRES_DB: openreader
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U openreader -d openreader"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 2s
|
|
||||||
retries: 30
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
compute-worker:
|
|
||||||
image: openreader-compute-worker:local
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: packages/compute-worker/Dockerfile
|
|
||||||
depends_on:
|
|
||||||
nats:
|
|
||||||
condition: service_started
|
|
||||||
seaweedfs:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
NATS_URL: nats://nats:4222
|
|
||||||
COMPUTE_WORKER_HOST: 0.0.0.0
|
|
||||||
PORT: 8081
|
|
||||||
COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token}
|
|
||||||
S3_ENDPOINT: http://seaweedfs:8333
|
|
||||||
S3_BUCKET: ${S3_BUCKET:-openreader-documents}
|
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
|
||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-devkey}
|
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-devsecret}
|
|
||||||
S3_FORCE_PATH_STYLE: "true"
|
|
||||||
S3_PREFIX: ${S3_PREFIX:-openreader}
|
|
||||||
COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-false}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openreader-docstore:
|
|
||||||
seaweedfs-data:
|
|
||||||
nats-data:
|
|
||||||
postgres-data:
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
name: openreader-local-slim-build
|
|
||||||
|
|
||||||
services:
|
|
||||||
openreader:
|
|
||||||
image: openreader:local
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
depends_on:
|
|
||||||
kokoro-tts:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
|
||||||
RUNTIME_SEED_JSON: |-
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"runtimeConfig": {
|
|
||||||
"defaultTtsProvider": "kokoro"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"slug": "kokoro",
|
|
||||||
"displayName": "Kokoro",
|
|
||||||
"providerType": "custom-openai",
|
|
||||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
|
||||||
"defaultModel": "kokoro",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
- "8333:8333"
|
|
||||||
volumes:
|
|
||||||
- openreader-docstore:/app/docstore
|
|
||||||
|
|
||||||
kokoro-tts:
|
|
||||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
|
||||||
environment:
|
|
||||||
ONNX_NUM_THREADS: 8
|
|
||||||
ONNX_INTER_OP_THREADS: 4
|
|
||||||
ONNX_EXECUTION_MODE: parallel
|
|
||||||
ONNX_OPTIMIZATION_LEVEL: all
|
|
||||||
ONNX_MEMORY_PATTERN: "true"
|
|
||||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
|
||||||
API_LOG_LEVEL: DEBUG
|
|
||||||
ports:
|
|
||||||
- "8880:8880"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openreader-docstore:
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
name: openreader-slim
|
|
||||||
|
|
||||||
services:
|
|
||||||
openreader:
|
|
||||||
image: ghcr.io/richardr1126/openreader:latest
|
|
||||||
depends_on:
|
|
||||||
kokoro-tts:
|
|
||||||
condition: service_started
|
|
||||||
environment:
|
|
||||||
BASE_URL: ${BASE_URL:-http://localhost:3003}
|
|
||||||
AUTH_SECRET: ${AUTH_SECRET:-local-openreader-auth-secret-change-me}
|
|
||||||
RUNTIME_SEED_JSON: |-
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"runtimeConfig": {
|
|
||||||
"defaultTtsProvider": "kokoro"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"slug": "kokoro",
|
|
||||||
"displayName": "Kokoro",
|
|
||||||
"providerType": "custom-openai",
|
|
||||||
"baseUrl": "http://kokoro-tts:8880/v1",
|
|
||||||
"defaultModel": "kokoro",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
ports:
|
|
||||||
- "3003:3003"
|
|
||||||
- "8333:8333"
|
|
||||||
volumes:
|
|
||||||
- openreader-docstore:/app/docstore
|
|
||||||
|
|
||||||
kokoro-tts:
|
|
||||||
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
|
|
||||||
environment:
|
|
||||||
ONNX_NUM_THREADS: 8
|
|
||||||
ONNX_INTER_OP_THREADS: 4
|
|
||||||
ONNX_EXECUTION_MODE: parallel
|
|
||||||
ONNX_OPTIMIZATION_LEVEL: all
|
|
||||||
ONNX_MEMORY_PATTERN: "true"
|
|
||||||
ONNX_ARENA_EXTEND_STRATEGY: kNextPowerOfTwo
|
|
||||||
API_LOG_LEVEL: DEBUG
|
|
||||||
ports:
|
|
||||||
- "8880:8880"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
openreader-docstore:
|
|
||||||
|
|
@ -50,10 +50,10 @@ Whether users can supply their own personal built-in provider keys is controlled
|
||||||
|
|
||||||
### Auto-seeded "default-openai"
|
### Auto-seeded "default-openai"
|
||||||
|
|
||||||
On first boot, if `admin_providers` is empty and `API_BASE` or `API_KEY` is set, OpenReader creates a single shared provider with:
|
On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with:
|
||||||
|
|
||||||
- slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai`
|
- slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai`
|
||||||
- baseUrl from `API_BASE`, apiKey from `API_KEY` when provided (blank keys are supported)
|
- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted)
|
||||||
- defaultModel set to `kokoro` (you can edit it in Admin → Shared providers)
|
- defaultModel set to `kokoro` (you can edit it in Admin → Shared providers)
|
||||||
|
|
||||||
After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient.
|
After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient.
|
||||||
|
|
@ -117,17 +117,6 @@ At the end of the **Site features** tab, a dedicated **TTS upstream** group cont
|
||||||
|
|
||||||
In v4 these settings are admin-only and are no longer configurable through environment variables.
|
In v4 these settings are admin-only and are no longer configurable through environment variables.
|
||||||
|
|
||||||
## Scheduled tasks
|
|
||||||
|
|
||||||
The **Scheduled tasks** section controls background maintenance jobs such as expired-upload cleanup, orphaned-blob reaping, and rate-limit ledger pruning.
|
|
||||||
|
|
||||||
- Enable or disable each task, adjust its interval, or run it immediately.
|
|
||||||
- Runs use database-backed leases so multiple app instances do not normally execute the same task concurrently.
|
|
||||||
- A task that exceeds four minutes is aborted and recorded as failed. A crashed run can be reclaimed after its stale lease expires.
|
|
||||||
- Failures and the latest successful summary appear on the task card and in server logs.
|
|
||||||
|
|
||||||
Self-hosted Node.js deployments tick the scheduler in-process once per minute. Vercel uses the authenticated `/api/admin/tasks/tick` cron route; the checked-in Vercel Hobby schedule runs once daily, so intervals shorter than one day are unavailable there. See [Vercel Deployment](../deploy/vercel-deployment#5-scheduled-maintenance-tasks).
|
|
||||||
|
|
||||||
## Migrating off env vars
|
## Migrating off env vars
|
||||||
|
|
||||||
In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:
|
In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:
|
||||||
|
|
@ -135,7 +124,7 @@ In v4, runtime site features are managed by admin settings and optional JSON see
|
||||||
1. Deploy this version with your existing env values in place.
|
1. Deploy this version with your existing env values in place.
|
||||||
2. Boot the app once. Open Settings → Admin and verify:
|
2. Boot the app once. Open Settings → Admin and verify:
|
||||||
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
|
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
|
||||||
- A `default-openai` row exists in **Shared providers** (if you had `API_BASE` or `API_KEY` set).
|
- A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
|
||||||
3. Remove any bootstrap env vars you no longer need from `.env`.
|
3. Remove any bootstrap env vars you no longer need from `.env`.
|
||||||
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,6 @@ import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
This page covers migration behavior for both database schema and storage data in OpenReader.
|
This page covers migration behavior for both database schema and storage data in OpenReader.
|
||||||
|
|
||||||
## Runtime ownership
|
|
||||||
|
|
||||||
- `@openreader/database` owns database clients, schemas, SQL migration files, and programmatic
|
|
||||||
migration execution for SQLite and PostgreSQL.
|
|
||||||
- `@openreader/bootstrap` owns startup orchestration, storage migration, and optional embedded
|
|
||||||
SeaweedFS, NATS, and compute-worker processes.
|
|
||||||
- The Next.js app imports `@openreader/database` directly, but does not orchestrate migrations or
|
|
||||||
child processes.
|
|
||||||
|
|
||||||
Docker deploys bootstrap as an isolated runtime bundle under `/opt/openreader/bootstrap`; it does
|
|
||||||
not merge migration dependencies into the standalone Next.js app under `/app`.
|
|
||||||
|
|
||||||
## Startup migration behavior
|
## Startup migration behavior
|
||||||
|
|
||||||
By default, the shared entrypoint runs migrations automatically before app startup in:
|
By default, the shared entrypoint runs migrations automatically before app startup in:
|
||||||
|
|
@ -65,6 +53,11 @@ In most cases, you do not need manual migration commands because startup runs mi
|
||||||
- Postgres when `POSTGRES_URL` is set
|
- Postgres when `POSTGRES_URL` is set
|
||||||
- SQLite when `POSTGRES_URL` is unset
|
- SQLite when `POSTGRES_URL` is unset
|
||||||
|
|
||||||
|
You can always override the target explicitly with `--config`.
|
||||||
|
|
||||||
|
<Tabs groupId="apply-migration-commands">
|
||||||
|
<TabItem value="project-scripts" label="Project Scripts" default>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run pending migrations for one target:
|
# Run pending migrations for one target:
|
||||||
# - Postgres if POSTGRES_URL is set
|
# - Postgres if POSTGRES_URL is set
|
||||||
|
|
@ -78,15 +71,26 @@ pnpm migrate-fs
|
||||||
pnpm migrate-fs:dry-run
|
pnpm migrate-fs:dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm migrate` uses the programmatic Drizzle migrator from `@openreader/database`. Drizzle Kit is
|
</TabItem>
|
||||||
not a production or startup dependency; it is used only to generate new migration files.
|
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Migrate SQLite
|
||||||
|
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
|
||||||
|
|
||||||
|
# Migrate Postgres
|
||||||
|
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
## Generate migrations
|
## Generate migrations
|
||||||
|
|
||||||
`pnpm generate` is a two-phase script for contributors and schema changes:
|
`pnpm generate` is a two-phase script for contributors and schema changes:
|
||||||
|
|
||||||
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
|
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
|
||||||
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both configs in `packages/database`, producing SQL migration files from all schema files (app + auth).
|
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
|
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
|
||||||
|
|
@ -96,23 +100,22 @@ Most users do not need to run `pnpm generate`. Use it when contributing or when
|
||||||
|
|
||||||
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
|
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
|
||||||
|
|
||||||
- `packages/database/src/schema_auth_sqlite.ts`
|
- `src/db/schema_auth_sqlite.ts`
|
||||||
- `packages/database/src/schema_auth_postgres.ts`
|
- `src/db/schema_auth_postgres.ts`
|
||||||
|
|
||||||
App-specific tables are manually maintained in the standard Drizzle schema files:
|
App-specific tables are manually maintained in the standard Drizzle schema files:
|
||||||
|
|
||||||
- `packages/database/src/schema_sqlite.ts`
|
- `src/db/schema_sqlite.ts`
|
||||||
- `packages/database/src/schema_postgres.ts`
|
- `src/db/schema_postgres.ts`
|
||||||
|
|
||||||
Both sets of schema files are included in the Drizzle generation configs. Runtime migration
|
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
|
||||||
execution is owned by `@openreader/database`.
|
|
||||||
|
|
||||||
When app schema changes (for example `tts_segments`), keep these in sync:
|
When app schema changes (for example `tts_segments`), keep these in sync:
|
||||||
|
|
||||||
- `packages/database/src/schema_sqlite.ts`
|
- `src/db/schema_sqlite.ts`
|
||||||
- `packages/database/src/schema_postgres.ts`
|
- `src/db/schema_postgres.ts`
|
||||||
- `packages/database/migrations/sqlite/*.sql` + `packages/database/migrations/sqlite/meta/_journal.json`
|
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
|
||||||
- `packages/database/migrations/postgres/*.sql` + `packages/database/migrations/postgres/meta/_journal.json`
|
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
|
||||||
|
|
||||||
<Tabs groupId="generate-migration-commands">
|
<Tabs groupId="generate-migration-commands">
|
||||||
<TabItem value="project-script" label="Project Script" default>
|
<TabItem value="project-script" label="Project Script" default>
|
||||||
|
|
@ -127,10 +130,10 @@ pnpm generate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate SQLite migrations only (skips Better Auth CLI)
|
# Generate SQLite migrations only (skips Better Auth CLI)
|
||||||
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.sqlite.ts
|
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
|
||||||
|
|
||||||
# Generate Postgres migrations only (skips Better Auth CLI)
|
# Generate Postgres migrations only (skips Better Auth CLI)
|
||||||
pnpm exec drizzle-kit generate --config packages/database/drizzle.config.pg.ts
|
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,12 @@ Use any OpenAI-compatible TTS service with OpenReader, including self-hosted ser
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Your service only needs an OpenAI-compatible speech endpoint:
|
Your service must expose these endpoints:
|
||||||
|
|
||||||
- `POST /v1/audio/speech` — **required**.
|
- `GET /v1/audio/voices`
|
||||||
- Voice listing is **optional** and auto-discovered from `/v1/audio/voices`, `/v1/voices`, or `/v1/styles`. If none respond, OpenReader falls back to default voices — the Kokoro voice set for Kokoro models, otherwise the standard OpenAI voices (`alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`).
|
- `POST /v1/audio/speech`
|
||||||
|
|
||||||
The endpoint may return `mp3`, `wav`, `ogg`, or `flac` — OpenReader normalizes non-mp3 audio to mp3 automatically. An API key is optional.
|
Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS-FastAPI](./kitten-tts-fastapi), [Orpheus-FastAPI](./orpheus-fastapi).
|
||||||
|
|
||||||
Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS-FastAPI](./kitten-tts-fastapi), [Orpheus-FastAPI](./orpheus-fastapi), [Supertonic](./supertonic).
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
@ -28,7 +26,7 @@ Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://your-tts-server/v1
|
API_BASE=http://your-tts-server/v1
|
||||||
# API_KEY=optional-key-if-required
|
API_KEY=optional-key-if-required
|
||||||
```
|
```
|
||||||
|
|
||||||
**Or in-app via Settings → TTS Provider:**
|
**Or in-app via Settings → TTS Provider:**
|
||||||
|
|
@ -46,7 +44,7 @@ See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If voices don't load, confirm the server is reachable from the Next.js runtime and that at least one of `/v1/audio/voices`, `/v1/voices`, or `/v1/styles` returns a valid response. If none do, OpenReader falls back to default voices — synthesis still works as long as `POST /v1/audio/speech` succeeds.
|
If voices don't load, check that `/v1/audio/voices` is reachable from the server and returns a valid response shape.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
---
|
|
||||||
title: Speech SDK
|
|
||||||
---
|
|
||||||
|
|
||||||
Use [speech-sdk](https://github.com/Jellypod-Inc/speech-sdk) (Apache 2.0) to reach additional cloud TTS providers (ElevenLabs, Cartesia, Hume, Deepgram, Google Gemini TTS, Inworld, and more) with your own provider API keys. Requests go from the OpenReader server directly to the provider's API; no extra account or proxy is involved.
|
|
||||||
|
|
||||||
Models use the `provider/model` format. The API key you enter belongs to the provider named by the model prefix: for `elevenlabs/eleven_multilingual_v2` enter an ElevenLabs key, for `cartesia/sonic-3.5` a Cartesia key, and so on.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
**Recommended (auth + admin): Settings → Admin → Shared providers**
|
|
||||||
|
|
||||||
1. Add a shared provider with type `speech-sdk`.
|
|
||||||
2. Enter the API key for the provider you want to use.
|
|
||||||
3. Set default model to a matching `provider/model` (for example `elevenlabs/eleven_multilingual_v2`).
|
|
||||||
|
|
||||||
**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
|
|
||||||
|
|
||||||
1. Set provider to `Speech SDK`.
|
|
||||||
2. Choose a model; enter the API key for that model's provider.
|
|
||||||
3. Choose a voice.
|
|
||||||
|
|
||||||
See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
|
|
||||||
|
|
||||||
## Built-in models
|
|
||||||
|
|
||||||
- `openai/gpt-4o-mini-tts` (works with your existing OpenAI API key)
|
|
||||||
- `elevenlabs/eleven_multilingual_v2`
|
|
||||||
- `cartesia/sonic-3.5`
|
|
||||||
- `deepgram/aura-2`
|
|
||||||
- `google/gemini-2.5-flash-preview-tts`
|
|
||||||
- `inworld/inworld-tts-1.5-max`
|
|
||||||
|
|
||||||
You can also choose `Other` and enter any `provider/model` the SDK supports. Recognized prefixes: `openai`, `elevenlabs`, `cartesia`, `hume`, `deepgram`, `google`, `inworld`, `minimax`, `fish-audio`, `murf`, `resemble`, `fal-ai`, `mistral`, `xai`, `smallest-ai`.
|
|
||||||
|
|
||||||
## Voice IDs
|
|
||||||
|
|
||||||
ElevenLabs and Cartesia identify voices by opaque IDs. The built-in lists map to these shared library voices:
|
|
||||||
|
|
||||||
| ElevenLabs ID | Name | | Cartesia ID | Name |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `JBFqnCBsd6RMkjVDRZzb` | George | | `a0e99841-438c-4a64-b679-ae501e7d6091` | Barbershop Man |
|
|
||||||
| `IKne3meq5aSn9XLyUdCD` | Charlie | | `156fb8d2-335b-4950-9cb3-a2d33f0c0c2a` | British Lady |
|
|
||||||
| `XB0fDUnXU5powFXDhCwa` | Charlotte | | `694f9389-aac1-45b6-b726-9d9369183238` | California Girl |
|
|
||||||
| `Xb7hH8MSUJpSbSDYk0k2` | Alice | | `87748186-23bb-4571-8b8b-a73da9bf9c4f` | Commercial Lady |
|
|
||||||
| `iP95p4xoKVk53GoZ742B` | Chris | | `ee7ea9f8-c0c1-498c-9f62-dc2da49a6f98` | Friendly Reading Man |
|
|
||||||
| `nPczCjzI2devNBz1zQrb` | Brian | | `248be419-c632-4f23-adf1-5324ed7dbf1d` | Hannah |
|
|
||||||
| `onwK4e9ZLuTAKqWW03F9` | Daniel | | | |
|
|
||||||
| `pFZP5JQG7iQjIQuC4Bku` | Lily | | | |
|
|
||||||
| `pqHfZKP75CvOlQylNhV4` | Bill | | | |
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- One voice per request; Kokoro-style multi-voice mixing does not apply to this provider.
|
|
||||||
- Playback speed is applied client-side, so cached audio segments stay valid when you change speed.
|
|
||||||
- Providers without a built-in voice list fall back to a `default` entry, which lets the provider pick its default voice.
|
|
||||||
- Word-by-word highlighting works the same as with every other provider (alignment runs in OpenReader, not the provider).
|
|
||||||
- TTS requests are sent from the server, not the browser. The API key is never exposed to clients.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [speech-sdk on GitHub](https://github.com/Jellypod-Inc/speech-sdk)
|
|
||||||
- [TTS Providers](../tts-providers)
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
---
|
|
||||||
title: Supertonic
|
|
||||||
---
|
|
||||||
|
|
||||||
Run [Supertonic](https://github.com/supertone-inc/supertonic-py) locally and connect it to OpenReader using the `Custom OpenAI-Like` provider. Supertonic is a fast, on-device TTS engine that ships its own OpenAI-compatible HTTP server.
|
|
||||||
|
|
||||||
:::note No Docker image
|
|
||||||
Supertonic does not publish a Docker image — it installs as a Python package and runs as a local HTTP server. These instructions assume OpenReader itself runs in Docker (the common case); see [Running OpenReader directly on the host](#running-openreader-directly-on-the-host) if you don't.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Run Supertonic
|
|
||||||
|
|
||||||
Install with `pip` (or `pipx` for an isolated install) and start the server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install 'supertonic[serve]' # or: pip install 'supertonic[serve]'
|
|
||||||
supertonic serve # defaults; loopback only
|
|
||||||
```
|
|
||||||
|
|
||||||
The first run downloads the model (~400MB). Once it's up, the OpenAI-compatible endpoint is at `http://127.0.0.1:7788/v1/audio/speech` and interactive docs are at `http://127.0.0.1:7788/docs`.
|
|
||||||
|
|
||||||
- **Models:** `supertonic-3` (default) or `supertonic-2`.
|
|
||||||
- **Voices:** built-ins `M1`–`M5` and `F1`–`F5`, plus any custom voices you import. OpenReader discovers them automatically via the `/v1/styles` endpoint.
|
|
||||||
- **Audio format:** Supertonic emits `wav` by default; OpenReader transcodes it to mp3 transparently, so no extra configuration is needed.
|
|
||||||
|
|
||||||
## Connect to OpenReader
|
|
||||||
|
|
||||||
From a Docker container, your host machine is reachable at `host.docker.internal`, so the base URL is `http://host.docker.internal:7788/v1`. On Docker Desktop (macOS/Windows) this reaches the loopback-bound server above with no extra setup.
|
|
||||||
|
|
||||||
:::note Linux (native Docker Engine)
|
|
||||||
On native Linux Docker, `host.docker.internal` needs `--add-host=host.docker.internal:host-gateway` on the OpenReader container (or the equivalent `extra_hosts` entry in `docker-compose.yml`), and it routes to the host's bridge interface rather than loopback. Pick one:
|
|
||||||
|
|
||||||
- Run the OpenReader container with `--network host` (or `network_mode: host`), keep Supertonic on `--host 127.0.0.1`, and use `http://127.0.0.1:7788/v1` as the base URL.
|
|
||||||
- Or start Supertonic with `--host 0.0.0.0` so the bridge can reach it — keep it on a trusted network or behind a firewall.
|
|
||||||
:::
|
|
||||||
|
|
||||||
**Recommended (auth + admin): Settings → Admin → Shared providers**
|
|
||||||
|
|
||||||
1. Add a shared provider with type `custom-openai`.
|
|
||||||
2. Set base URL to `http://host.docker.internal:7788/v1`.
|
|
||||||
3. Leave API key blank — `supertonic serve` does not require one.
|
|
||||||
4. Set default model to `supertonic-3` (or `supertonic-2`).
|
|
||||||
|
|
||||||
**Legacy bootstrap seed (optional, first boot only):**
|
|
||||||
|
|
||||||
```env
|
|
||||||
API_BASE=http://host.docker.internal:7788/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Or in-app via Settings → TTS Provider:**
|
|
||||||
|
|
||||||
1. Set provider to `Custom OpenAI-Like`.
|
|
||||||
2. Set `API_BASE` to `http://host.docker.internal:7788/v1`.
|
|
||||||
3. Leave `API_KEY` blank.
|
|
||||||
4. Choose model `supertonic-3` (or the model your deployment exposes).
|
|
||||||
|
|
||||||
See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
|
|
||||||
|
|
||||||
## Running OpenReader directly on the host
|
|
||||||
|
|
||||||
If OpenReader runs on the same machine (e.g. `pnpm dev`) rather than in Docker, skip `host.docker.internal` and use `http://127.0.0.1:7788/v1` as the base URL everywhere above.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [supertone-inc/supertonic-py](https://github.com/supertone-inc/supertonic-py)
|
|
||||||
- [Supported Languages](https://github.com/supertone-inc/supertonic-py#supported-languages)
|
|
||||||
- [TTS Providers](../tts-providers)
|
|
||||||
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
|
|
||||||
|
|
@ -19,7 +19,6 @@ If you're running a private/self-hosted instance and want per-user BYOK behavior
|
||||||
- **OpenAI**: Cloud. Base URL pre-filled (`https://api.openai.com/v1`). API key required.
|
- **OpenAI**: Cloud. Base URL pre-filled (`https://api.openai.com/v1`). API key required.
|
||||||
- **Replicate**: Cloud. Base URL managed internally by OpenReader. API key required.
|
- **Replicate**: Cloud. Base URL managed internally by OpenReader. API key required.
|
||||||
- **DeepInfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required.
|
- **DeepInfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required.
|
||||||
- **Speech SDK**: Cloud. Reaches additional providers (ElevenLabs, Cartesia, Hume, Deepgram, Google, Inworld, and more) directly with your own provider API keys via [speech-sdk](./tts-provider-guides/speech-sdk). No base URL. API key required (the key for the model's provider).
|
|
||||||
- **Custom OpenAI-Like**: Self-hosted or any custom endpoint. `API_BASE` must be set manually (typically ending in `/v1`). API key optional.
|
- **Custom OpenAI-Like**: Self-hosted or any custom endpoint. `API_BASE` must be set manually (typically ending in `/v1`). API key optional.
|
||||||
|
|
||||||
For `OpenAI`, `DeepInfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`.
|
For `OpenAI`, `DeepInfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`.
|
||||||
|
|
@ -29,16 +28,13 @@ For `OpenAI`, `DeepInfra`, and `Replicate` you only need to supply an API key. F
|
||||||
- **Replicate** models: `alphanumericuser/kokoro-82m`, `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` (or choose `Other` and enter any Replicate model ID, such as `owner/model` or `owner/model:version`)
|
- **Replicate** models: `alphanumericuser/kokoro-82m`, `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` (or choose `Other` and enter any Replicate model ID, such as `owner/model` or `owner/model:version`)
|
||||||
- **OpenAI** models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`
|
- **OpenAI** models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`
|
||||||
- **DeepInfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags)
|
- **DeepInfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags)
|
||||||
- **Speech SDK** models: `openai/gpt-4o-mini-tts`, `elevenlabs/eleven_multilingual_v2`, `cartesia/sonic-3.5`, `deepgram/aura-2`, `google/gemini-2.5-flash-preview-tts`, `inworld/inworld-tts-1.5-max` (or choose `Other` and enter any `provider/model` the SDK supports)
|
|
||||||
|
|
||||||
## Custom provider requirements
|
## Custom provider requirements
|
||||||
|
|
||||||
Self-hosted or custom providers only need an OpenAI-compatible speech endpoint:
|
Self-hosted or custom providers must expose OpenAI-compatible audio endpoints:
|
||||||
|
|
||||||
- `POST /v1/audio/speech` — **required**.
|
- `GET /v1/audio/voices`
|
||||||
- Voice listing is **optional** and auto-discovered: OpenReader probes `/v1/audio/voices`, `/v1/voices`, then `/v1/styles`. If none respond, it falls back to default voices — the Kokoro set for Kokoro models, otherwise the standard OpenAI voices (`alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`).
|
- `POST /v1/audio/speech`
|
||||||
|
|
||||||
The speech endpoint may return any common audio format — `mp3`, `wav`, `ogg`, or `flac`. OpenReader detects the format and transcodes non-mp3 audio to mp3 automatically, so your server does not need to honor `response_format: mp3`. An API key is optional; keyless servers work.
|
|
||||||
|
|
||||||
:::warning TTS requests are server-side
|
:::warning TTS requests are server-side
|
||||||
TTS requests originate from the **Next.js server**, not the browser. `API_BASE` must be reachable from the server runtime. In Docker, use container names or `host.docker.internal` rather than `localhost`.
|
TTS requests originate from the **Next.js server**, not the browser. `API_BASE` must be reachable from the server runtime. In Docker, use container names or `host.docker.internal` rather than `localhost`.
|
||||||
|
|
@ -49,11 +45,9 @@ TTS requests originate from the **Next.js server**, not the browser. `API_BASE`
|
||||||
- [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi)
|
- [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi)
|
||||||
- [KittenTTS-FastAPI](./tts-provider-guides/kitten-tts-fastapi)
|
- [KittenTTS-FastAPI](./tts-provider-guides/kitten-tts-fastapi)
|
||||||
- [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi)
|
- [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi)
|
||||||
- [Supertonic](./tts-provider-guides/supertonic)
|
|
||||||
- [Replicate](./tts-provider-guides/replicate)
|
- [Replicate](./tts-provider-guides/replicate)
|
||||||
- [DeepInfra](./tts-provider-guides/deepinfra)
|
- [DeepInfra](./tts-provider-guides/deepinfra)
|
||||||
- [OpenAI](./tts-provider-guides/openai)
|
- [OpenAI](./tts-provider-guides/openai)
|
||||||
- [Speech SDK](./tts-provider-guides/speech-sdk)
|
|
||||||
- [Other](./tts-provider-guides/other)
|
- [Other](./tts-provider-guides/other)
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,6 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
|
- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
|
||||||
- PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Rate limiting](./admin-panel#rate-limiting)
|
- PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Site features](./admin-panel#site-features) and [Environment Variables](../reference/environment-variables#compute-pdf-parsing-rate-limiting-runtime-settings)
|
||||||
- Auth configuration: [Auth](./auth)
|
- Auth configuration: [Auth](./auth)
|
||||||
- Provider setup: [TTS Providers](./tts-providers)
|
- Provider setup: [TTS Providers](./tts-providers)
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@ Use this guide when OpenReader runs compute as a separate service. For the defau
|
||||||
- Runs PDF layout parsing jobs
|
- Runs PDF layout parsing jobs
|
||||||
- Stores durable job state in NATS JetStream and NATS KV
|
- Stores durable job state in NATS JetStream and NATS KV
|
||||||
|
|
||||||
The app server submits resource-specific operations under `/v1` and listens for updates on
|
The app server submits work to `POST /ops` and listens for updates on `GET /ops/:opId/events`.
|
||||||
`GET /v1/operations/:opId/events`.
|
|
||||||
|
|
||||||
## When to use it
|
## When to use it
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ S3_SECRET_ACCESS_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
:::important
|
:::important
|
||||||
`compute-worker/.env*` is only for standalone worker deployments.
|
`compute/worker/.env*` is only for standalone worker deployments.
|
||||||
|
|
||||||
- Embedded/local mode: configure the root `.env` only.
|
- Embedded/local mode: configure the root `.env` only.
|
||||||
- External worker mode: set `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` on the app, and worker runtime values on the worker service.
|
- External worker mode: set `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` on the app, and worker runtime values on the worker service.
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
---
|
|
||||||
title: Docker Compose
|
|
||||||
description: Run OpenReader with the slim, full, local-slim, or local-full Docker Compose examples.
|
|
||||||
---
|
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
Use these examples to run OpenReader with Kokoro-FastAPI and persistent storage. Choose the slim
|
|
||||||
stack for the simplest deployment, or the full stack when you want PostgreSQL, SeaweedFS, NATS,
|
|
||||||
and the compute worker as separate containers. Local build variants are also available for both slim
|
|
||||||
and full stacks to build the application from your current checkout.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- A recent Docker version with Docker Compose
|
|
||||||
- A clone of the OpenReader repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/richardr1126/openreader.git
|
|
||||||
cd openreader
|
|
||||||
```
|
|
||||||
|
|
||||||
## Choose a stack
|
|
||||||
|
|
||||||
<Tabs groupId="docker-compose-stack">
|
|
||||||
<TabItem value="slim" label="Slim" default>
|
|
||||||
|
|
||||||
The default slim example runs:
|
|
||||||
|
|
||||||
- OpenReader with embedded SeaweedFS, NATS, compute worker, and SQLite
|
|
||||||
- Kokoro-FastAPI as a companion container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/examples/compose.yml up
|
|
||||||
# Repository convenience command: pnpm compose
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose file: [`docker/examples/compose.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.yml)
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="full" label="Full">
|
|
||||||
|
|
||||||
The full example runs OpenReader, Kokoro-FastAPI, PostgreSQL, SeaweedFS, NATS, and the compute
|
|
||||||
worker as separate containers using published images.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/examples/compose.full.yml up
|
|
||||||
# Repository convenience command: pnpm compose:full
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose file: [`docker/examples/compose.full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.full.yml)
|
|
||||||
|
|
||||||
For details about running the worker separately, see
|
|
||||||
[Compute Worker](./compute-worker).
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="local-slim" label="Local Slim">
|
|
||||||
|
|
||||||
The local-slim example runs a slim setup (OpenReader and Kokoro-FastAPI), but builds the OpenReader app image from the current checkout.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/examples/compose.local-slim.yml up --build
|
|
||||||
# Repository convenience command: pnpm compose:local
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose file: [`docker/examples/compose.local-slim.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-slim.yml)
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="local-full" label="Local Full">
|
|
||||||
|
|
||||||
The local-full example uses the full multi-container layout, but builds the OpenReader app and compute-worker images from the current checkout.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/examples/compose.local-full.yml up --build
|
|
||||||
# Repository convenience command: pnpm compose:local:full
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose file: [`docker/examples/compose.local-full.yml`](https://github.com/richardr1126/openreader/blob/main/docker/examples/compose.local-full.yml)
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Included services
|
|
||||||
|
|
||||||
| Service | Slim | Full | Local Slim | Local Full |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| OpenReader | Published image | Published image | Local build | Local build |
|
|
||||||
| Kokoro-FastAPI | Container | Container | Container | Container |
|
|
||||||
| Database | Embedded SQLite | PostgreSQL container | Embedded SQLite | PostgreSQL container |
|
|
||||||
| SeaweedFS | Embedded | Container | Embedded | Container |
|
|
||||||
| NATS | Embedded | Container | Embedded | Container |
|
|
||||||
| Compute worker | Embedded | Published image | Embedded | Local build |
|
|
||||||
|
|
||||||
On first boot, `RUNTIME_SEED_JSON` creates an enabled Kokoro shared provider and selects it as the
|
|
||||||
default TTS provider.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
- OpenReader: `http://localhost:3003`
|
|
||||||
- SeaweedFS S3: `http://localhost:8333`
|
|
||||||
- Kokoro-FastAPI: `http://localhost:8880`
|
|
||||||
|
|
||||||
In the full examples, PostgreSQL, the compute worker, and NATS remain internal to the Compose
|
|
||||||
network.
|
|
||||||
|
|
||||||
## LAN access
|
|
||||||
|
|
||||||
Set `BASE_URL` and `S3_ENDPOINT` to the Docker host's LAN IP so browser-facing app and presigned
|
|
||||||
S3 URLs are reachable from other devices:
|
|
||||||
|
|
||||||
<Tabs groupId="docker-compose-lan-stack">
|
|
||||||
<TabItem value="slim" label="Slim" default>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://192.168.0.XXX:3003 \
|
|
||||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
|
||||||
docker compose -f docker/examples/compose.yml up
|
|
||||||
# Repository convenience command: pnpm compose
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="full" label="Full">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://192.168.0.XXX:3003 \
|
|
||||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
|
||||||
docker compose -f docker/examples/compose.full.yml up
|
|
||||||
# Repository convenience command: pnpm compose:full
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="local-slim" label="Local Slim">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://192.168.0.XXX:3003 \
|
|
||||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
|
||||||
docker compose -f docker/examples/compose.local-slim.yml up --build
|
|
||||||
# Repository convenience command: pnpm compose:local
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="local-full" label="Local Full">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://192.168.0.XXX:3003 \
|
|
||||||
S3_ENDPOINT=http://192.168.0.XXX:8333 \
|
|
||||||
docker compose -f docker/examples/compose.local-full.yml up --build
|
|
||||||
# Repository convenience command: pnpm compose:local:full
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
Replace `192.168.0.XXX` with your Docker host's LAN IP and allow inbound TCP ports `3003` and
|
|
||||||
`8333` through its firewall.
|
|
||||||
|
|
||||||
:::info Internal full-stack endpoint
|
|
||||||
The full and local-full compute workers continue using `http://seaweedfs:8333` internally.
|
|
||||||
`S3_ENDPOINT` configures the app endpoint and browser-facing presigned URLs.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The examples use local-only default credentials. Override existing `${VARIABLE}` values through
|
|
||||||
your shell environment before using them beyond local development.
|
|
||||||
|
|
||||||
:::warning Protect public deployments
|
|
||||||
Replace the default `AUTH_SECRET`, PostgreSQL credentials, S3 credentials, and compute-worker
|
|
||||||
token before exposing a stack outside your trusted local network.
|
|
||||||
:::
|
|
||||||
|
|
||||||
For the complete configuration reference, see
|
|
||||||
[Environment Variables](../reference/environment-variables). See [Database](../configure/database)
|
|
||||||
for PostgreSQL and SQLite behavior.
|
|
||||||
|
|
@ -159,10 +159,45 @@ If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` i
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
:::tip Docker Compose
|
<details>
|
||||||
To run OpenReader and Kokoro-FastAPI with Docker Compose, including slim, full, and local-build
|
<summary><strong>External compute worker dev stack (optional)</strong></summary>
|
||||||
options, see [Docker Compose](./docker-compose).
|
|
||||||
:::
|
Use this only when you intentionally run compute-worker as a separate service.
|
||||||
|
Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`.
|
||||||
|
Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker).
|
||||||
|
|
||||||
|
Start only NATS + compute-worker via compose watch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch
|
||||||
|
# or: pnpm compute:dev:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
|
||||||
|
|
||||||
|
Run the main app separately on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
For app -> external worker routing, set in root `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
|
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ownership in external worker mode:
|
||||||
|
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale/retry overrides such as `COMPUTE_PDF_JOB_ATTEMPTS`
|
||||||
|
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
||||||
|
|
||||||
|
For embedded worker startup (`COMPUTE_WORKER_URL` unset), worker tuning values such as `COMPUTE_PDF_JOB_ATTEMPTS` must be set in the root `.env` because `compute/worker/.env*` is ignored in that mode.
|
||||||
|
|
||||||
|
Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
|
||||||
|
For external worker mode, object storage must be shared/reachable by both app and worker services.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
@ -205,7 +240,7 @@ COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||||
|
|
||||||
Use the same ownership split:
|
Use the same ownership split:
|
||||||
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
|
- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
|
||||||
- `compute-worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
|
||||||
|
|
||||||
Use one of these `.env` mode templates:
|
Use one of these `.env` mode templates:
|
||||||
|
|
||||||
|
|
@ -214,8 +249,9 @@ Use one of these `.env` mode templates:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
|
API_KEY=none
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
# Optional when you need multiple local origins:
|
# Optional when you need multiple local origins:
|
||||||
# AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003
|
# AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003
|
||||||
```
|
```
|
||||||
|
|
@ -224,11 +260,12 @@ AUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
||||||
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
|
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# API_BASE and optional API_KEY are seeded into the admin "default-openai" shared provider
|
# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider
|
||||||
# on first boot, then no longer read. Manage them in Settings → Admin afterwards.
|
# on first boot, then no longer read. Manage them in Settings → Admin afterwards.
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
|
API_KEY=none
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
# Comma-separated emails to auto-promote to admin on signin.
|
# Comma-separated emails to auto-promote to admin on signin.
|
||||||
ADMIN_EMAILS=you@example.com
|
ADMIN_EMAILS=you@example.com
|
||||||
```
|
```
|
||||||
|
|
@ -238,9 +275,10 @@ ADMIN_EMAILS=you@example.com
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
|
API_KEY=none
|
||||||
USE_EMBEDDED_WEED_MINI=false
|
USE_EMBEDDED_WEED_MINI=false
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
S3_BUCKET=your-bucket
|
S3_BUCKET=your-bucket
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_ACCESS_KEY_ID=your-access-key
|
S3_ACCESS_KEY_ID=your-access-key
|
||||||
|
|
@ -255,8 +293,9 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_BASE=http://host.docker.internal:8880/v1
|
API_BASE=http://host.docker.internal:8880/v1
|
||||||
|
API_KEY=none
|
||||||
BASE_URL=http://localhost:3003
|
BASE_URL=http://localhost:3003
|
||||||
AUTH_SECRET=<generate-with-openssl-rand-base64-32>
|
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
|
||||||
COMPUTE_WORKER_URL=http://localhost:8081
|
COMPUTE_WORKER_URL=http://localhost:8081
|
||||||
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
|
||||||
USE_EMBEDDED_WEED_MINI=false
|
USE_EMBEDDED_WEED_MINI=false
|
||||||
|
|
@ -290,10 +329,6 @@ Storage configuration details are in [Object / Blob Storage](../configure/object
|
||||||
Refer to [Database](../configure/database) for database modes.
|
Refer to [Database](../configure/database) for database modes.
|
||||||
Learn about migration behavior and commands in [Migrations](../configure/migrations).
|
Learn about migration behavior and commands in [Migrations](../configure/migrations).
|
||||||
|
|
||||||
:::info Scheduled maintenance tasks
|
|
||||||
Local and self-hosted Node.js deployments start the scheduled-task loop in-process and check for due work once per minute. No `CRON_SECRET` is required unless you intentionally invoke the cron HTTP route yourself. Manage task intervals and inspect failures from **Settings → Admin → Scheduled tasks**.
|
|
||||||
:::
|
|
||||||
|
|
||||||
4. Start the app.
|
4. Start the app.
|
||||||
|
|
||||||
<Tabs groupId="local-run-mode">
|
<Tabs groupId="local-run-mode">
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ S3_PREFIX=openreader
|
||||||
BASE_URL=https://your-app.vercel.app
|
BASE_URL=https://your-app.vercel.app
|
||||||
AUTH_SECRET=...
|
AUTH_SECRET=...
|
||||||
ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
|
ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
|
||||||
CRON_SECRET=... # generate with: openssl rand -base64 32
|
|
||||||
|
|
||||||
# Heavy compute (required on Vercel in current releases)
|
# Heavy compute (required on Vercel in current releases)
|
||||||
COMPUTE_WORKER_URL=https://<railway-worker-domain>
|
COMPUTE_WORKER_URL=https://<railway-worker-domain>
|
||||||
|
|
@ -106,20 +105,12 @@ For all variables and defaults, see [Environment Variables](../reference/environ
|
||||||
|
|
||||||
## 4. Database and data migrations
|
## 4. Database and data migrations
|
||||||
|
|
||||||
Vercel deployments do not run the `@openreader/bootstrap` process, so automatic startup migrations do not run there.
|
Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic startup migrations do not run there.
|
||||||
|
|
||||||
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
|
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
|
||||||
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.
|
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.
|
||||||
|
|
||||||
## 5. Scheduled maintenance tasks
|
## 5. FFmpeg packaging in Vercel functions
|
||||||
|
|
||||||
The repository configures `/api/admin/tasks/tick` as a Vercel Cron route. Set `CRON_SECRET`; requests without the matching bearer token are rejected.
|
|
||||||
|
|
||||||
The checked-in Hobby-compatible schedule invokes the route once daily. The admin task panel therefore prevents selecting intervals shorter than one day on Vercel, even though self-hosted deployments can run tasks more frequently.
|
|
||||||
|
|
||||||
Each due task is claimed with a database-backed lease, due tasks start independently, and individual runs are aborted and marked failed after four minutes. Review failures and run tasks manually from **Settings → Admin → Scheduled tasks**.
|
|
||||||
|
|
||||||
## 6. FFmpeg packaging in Vercel functions
|
|
||||||
|
|
||||||
`ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for:
|
`ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for:
|
||||||
|
|
||||||
|
|
@ -133,7 +124,7 @@ Each due task is claimed with a database-backed lease, due tasks start independe
|
||||||
|
|
||||||
If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly.
|
If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly.
|
||||||
|
|
||||||
## 7. Function memory sizing
|
## 6. Function memory sizing
|
||||||
|
|
||||||
FFmpeg workloads benefit from more memory/CPU. This repo includes:
|
FFmpeg workloads benefit from more memory/CPU. This repo includes:
|
||||||
|
|
||||||
|
|
@ -149,15 +140,14 @@ FFmpeg workloads benefit from more memory/CPU. This repo includes:
|
||||||
|
|
||||||
Adjust memory per route if your files are larger or your plan differs.
|
Adjust memory per route if your files are larger or your plan differs.
|
||||||
|
|
||||||
## 8. Runtime expectations and caveats
|
## 7. Runtime expectations and caveats
|
||||||
|
|
||||||
- Audiobook APIs require S3 configuration; otherwise they return `503`.
|
- Audiobook APIs require S3 configuration; otherwise they return `503`.
|
||||||
- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite.
|
- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite.
|
||||||
|
|
||||||
## 9. Smoke test after deploy
|
## 8. Smoke test after deploy
|
||||||
|
|
||||||
1. Upload and read a PDF/EPUB document.
|
1. Upload and read a PDF/EPUB document.
|
||||||
2. Confirm sync/blob fetch works across refreshes/devices.
|
2. Confirm sync/blob fetch works across refreshes/devices.
|
||||||
3. Generate at least one audiobook chapter and play/download it.
|
3. Generate at least one audiobook chapter and play/download it.
|
||||||
4. Verify worker-backed word highlighting and PDF parsing.
|
4. Verify worker-backed word highlighting and PDF parsing.
|
||||||
5. Open **Settings → Admin → Scheduled tasks**, run one task manually, and confirm the next daily cron invocation succeeds.
|
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,9 @@ docker run --name openreader \
|
||||||
-p 8333:8333 \
|
-p 8333:8333 \
|
||||||
-v openreader_docstore:/app/docstore \
|
-v openreader_docstore:/app/docstore \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
|
-e API_KEY=none \
|
||||||
-e BASE_URL=http://localhost:3003 \
|
-e BASE_URL=http://localhost:3003 \
|
||||||
-e AUTH_SECRET=$(openssl rand -base64 32) \
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
-e ADMIN_EMAILS=you@example.com \
|
-e ADMIN_EMAILS=you@example.com \
|
||||||
ghcr.io/richardr1126/openreader:latest
|
ghcr.io/richardr1126/openreader:latest
|
||||||
```
|
```
|
||||||
|
|
@ -53,7 +54,7 @@ What this command enables:
|
||||||
- `-p 3003:3003`: exposes the OpenReader web app/API.
|
- `-p 3003:3003`: exposes the OpenReader web app/API.
|
||||||
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
|
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
|
||||||
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
|
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
|
||||||
- `-e API_BASE=...` / optional `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest when provided). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
|
- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
|
||||||
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
|
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
|
||||||
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
|
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ docker run --name openreader \
|
||||||
-v openreader_docstore:/app/docstore \
|
-v openreader_docstore:/app/docstore \
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
-e API_BASE=http://host.docker.internal:8880/v1 \
|
||||||
-e BASE_URL=http://<YOUR_LAN_IP>:3003 \
|
-e BASE_URL=http://<YOUR_LAN_IP>:3003 \
|
||||||
-e AUTH_SECRET=$(openssl rand -base64 32) \
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
-e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \
|
-e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \
|
||||||
-e USE_ANONYMOUS_AUTH_SESSIONS=true \
|
-e USE_ANONYMOUS_AUTH_SESSIONS=true \
|
||||||
-e ADMIN_EMAILS=you@example.com \
|
-e ADMIN_EMAILS=you@example.com \
|
||||||
|
|
@ -86,7 +87,6 @@ What this command enables:
|
||||||
- `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin.
|
- `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin.
|
||||||
- `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled.
|
- `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled.
|
||||||
- `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that.
|
- `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that.
|
||||||
- `API_KEY` optionally seeds the default provider's key (encrypted at rest). Omit it for an upstream that does not require authentication.
|
|
||||||
- `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI.
|
- `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI.
|
||||||
- `openreader_docstore` volume keeps data persistent across restarts.
|
- `openreader_docstore` volume keeps data persistent across restarts.
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ docker run --name openreader \
|
||||||
-p 3003:3003 \
|
-p 3003:3003 \
|
||||||
-p 8333:8333 \
|
-p 8333:8333 \
|
||||||
-e BASE_URL=http://localhost:3003 \
|
-e BASE_URL=http://localhost:3003 \
|
||||||
-e AUTH_SECRET=$(openssl rand -base64 32) \
|
-e AUTH_SECRET=$(openssl rand -hex 32) \
|
||||||
ghcr.io/richardr1126/openreader:latest
|
ghcr.io/richardr1126/openreader:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ What this command enables:
|
||||||
- Fast startup with only the required auth env vars.
|
- Fast startup with only the required auth env vars.
|
||||||
- No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount.
|
- No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount.
|
||||||
- The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode.
|
- The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode.
|
||||||
- No TTS provider preset by default. Configure `API_BASE` and, when required, `API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel.
|
- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -122,7 +122,6 @@ What this command enables:
|
||||||
- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON.
|
- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON.
|
||||||
- Use a `/app/docstore` mount if you want data to survive container/image replacement.
|
- Use a `/app/docstore` mount if you want data to survive container/image replacement.
|
||||||
- Startup automatically runs DB/storage migrations via the shared entrypoint.
|
- Startup automatically runs DB/storage migrations via the shared entrypoint.
|
||||||
- Scheduled maintenance tasks run in-process and can be managed from **Settings → Admin → Scheduled tasks**; Docker/self-hosted deployments do not need `CRON_SECRET`.
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::warning Port `8333` Exposure
|
:::warning Port `8333` Exposure
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ title: Introduction
|
||||||
slug: /
|
slug: /
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenReader is an open-source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
|
OpenReader is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
|
||||||
|
|
||||||
> Previously named **OpenReader-WebUI**.
|
> Previously named **OpenReader-WebUI**.
|
||||||
|
|
||||||
|
|
@ -21,9 +21,6 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
|
||||||
- 🎯 **Multi-Provider TTS Support**
|
- 🎯 **Multi-Provider TTS Support**
|
||||||
- Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint
|
- Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint
|
||||||
- Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
|
- Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
|
||||||
- 🌐 **Multilingual Support**
|
|
||||||
- Choose a document language for language-aware narration and highlighting
|
|
||||||
- Available languages depend on the configured provider, model, and voice
|
|
||||||
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
|
||||||
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
|
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
|
||||||
- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
|
- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_P
|
||||||
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
|
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
|
||||||
| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
|
| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
|
||||||
| `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin |
|
| `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin |
|
||||||
| `CRON_SECRET` | Scheduled tasks | unset | Required for Vercel cron invocations |
|
|
||||||
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
|
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
|
||||||
| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only |
|
| `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_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
|
||||||
|
|
@ -86,7 +85,7 @@ App server log level.
|
||||||
Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
|
Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
|
||||||
|
|
||||||
- Example: `http://host.docker.internal:8880/v1`
|
- Example: `http://host.docker.internal:8880/v1`
|
||||||
- Read only for provider bootstrap when shared providers are empty. Setting `API_BASE` is sufficient; `API_KEY` may be blank.
|
- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set.
|
||||||
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
||||||
|
|
||||||
### API_KEY
|
### API_KEY
|
||||||
|
|
@ -97,6 +96,27 @@ Optional first-boot bootstrap API key for the auto-created `default-openai` shar
|
||||||
- Stored encrypted at rest after bootstrap.
|
- Stored encrypted at rest after bootstrap.
|
||||||
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
|
||||||
|
|
||||||
|
### TTS Daily Rate Limiting (Runtime Settings)
|
||||||
|
|
||||||
|
Managed as runtime config in **Settings → Admin → Site features**.
|
||||||
|
|
||||||
|
- `disableTtsRateLimit` default: `true` (daily TTS limits disabled)
|
||||||
|
- `ttsDailyLimitAnonymous` default: `50000`
|
||||||
|
- `ttsDailyLimitAuthenticated` default: `500000`
|
||||||
|
- `ttsIpDailyLimitAnonymous` default: `100000`
|
||||||
|
- `ttsIpDailyLimitAuthenticated` default: `1000000`
|
||||||
|
|
||||||
|
### TTS Upstream Settings (Runtime Settings)
|
||||||
|
|
||||||
|
Managed as runtime config in **Settings → Admin → Site features → TTS upstream**.
|
||||||
|
|
||||||
|
- `ttsUpstreamMaxRetries` default: `2`
|
||||||
|
- `ttsUpstreamTimeoutMs` default: `285000`
|
||||||
|
- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB)
|
||||||
|
- `ttsCacheTtlMs` default: `1800000` (30 minutes)
|
||||||
|
|
||||||
|
There are no dedicated env vars for these runtime settings.
|
||||||
|
|
||||||
## Auth and Identity
|
## Auth and Identity
|
||||||
|
|
||||||
### BASE_URL
|
### BASE_URL
|
||||||
|
|
@ -111,7 +131,7 @@ Required external base URL for this OpenReader instance.
|
||||||
Required secret key used by auth/session handling.
|
Required secret key used by auth/session handling.
|
||||||
|
|
||||||
- Required at startup
|
- Required at startup
|
||||||
- Generate with `openssl rand -base64 32`
|
- Generate with `openssl rand -hex 32`
|
||||||
|
|
||||||
### AUTH_TRUSTED_ORIGINS
|
### AUTH_TRUSTED_ORIGINS
|
||||||
|
|
||||||
|
|
@ -145,15 +165,6 @@ Comma-separated list of email addresses auto-promoted to admin.
|
||||||
- Requires auth to be enabled
|
- Requires auth to be enabled
|
||||||
- Admins can manage shared providers and runtime site features in-app
|
- Admins can manage shared providers and runtime site features in-app
|
||||||
|
|
||||||
### CRON_SECRET
|
|
||||||
|
|
||||||
Bearer-token secret for `GET /api/admin/tasks/tick`.
|
|
||||||
|
|
||||||
- Required on Vercel so scheduled maintenance tasks can run from the configured Vercel Cron.
|
|
||||||
- Vercel automatically sends `Authorization: Bearer <CRON_SECRET>` on cron invocations.
|
|
||||||
- Generate a strong random value, for example with `openssl rand -base64 32`.
|
|
||||||
- Self-hosted Node.js deployments run the scheduler in-process and do not require this variable.
|
|
||||||
|
|
||||||
## Database and Object Blob Storage
|
## Database and Object Blob Storage
|
||||||
|
|
||||||
### POSTGRES_URL
|
### POSTGRES_URL
|
||||||
|
|
@ -328,6 +339,19 @@ External compute worker URL.
|
||||||
|
|
||||||
Shared token for app-to-external-worker requests.
|
Shared token for app-to-external-worker requests.
|
||||||
|
|
||||||
|
## Compute PDF Parsing Rate Limiting (Runtime Settings)
|
||||||
|
|
||||||
|
Managed as runtime config in **Settings → Admin → Site features**.
|
||||||
|
|
||||||
|
- `disableComputeRateLimit` default: `true`
|
||||||
|
- `computeParseBurstMax` default: `8`
|
||||||
|
- `computeParseBurstWindowSec` default: `60`
|
||||||
|
- `computeParseSustainedMax` default: `24`
|
||||||
|
- `computeParseSustainedWindowSec` default: `600`
|
||||||
|
- `maxUploadMb` default: `200`
|
||||||
|
|
||||||
|
There are no dedicated env vars for these runtime settings.
|
||||||
|
|
||||||
## Audio Runtime
|
## Audio Runtime
|
||||||
|
|
||||||
### FFMPEG_BIN
|
### FFMPEG_BIN
|
||||||
|
|
@ -422,6 +446,7 @@ Example:
|
||||||
"displayName": "Default (seeded)",
|
"displayName": "Default (seeded)",
|
||||||
"providerType": "custom-openai",
|
"providerType": "custom-openai",
|
||||||
"baseUrl": "http://localhost:8880/v1",
|
"baseUrl": "http://localhost:8880/v1",
|
||||||
|
"apiKey": "api_key_optional",
|
||||||
"defaultModel": "kokoro",
|
"defaultModel": "kokoro",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
|
@ -432,7 +457,7 @@ Example:
|
||||||
Provider fallback behavior:
|
Provider fallback behavior:
|
||||||
|
|
||||||
- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped.
|
- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped.
|
||||||
- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty. `API_BASE` alone is sufficient for an upstream that does not require authentication.
|
- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty.
|
||||||
|
|
||||||
Precedence summary:
|
Precedence summary:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,20 +45,21 @@ title: Stack
|
||||||
|
|
||||||
## External compute worker (optional)
|
## External compute worker (optional)
|
||||||
|
|
||||||
Standalone worker package:
|
Monorepo packages under `compute/`:
|
||||||
|
|
||||||
- **`@openreader/compute-worker`** — standalone Node.js compute service containing its private inference and queue runtime
|
- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts
|
||||||
- ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
|
- ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
|
||||||
- Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps
|
- Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps
|
||||||
- PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing
|
- PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing
|
||||||
- PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization
|
- PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization
|
||||||
- Utilities: `jszip`, `ffmpeg-static`
|
- Utilities: `jszip`, `ffmpeg-static`
|
||||||
- HTTP server: [Fastify](https://fastify.dev/) v5 with a versioned OpenAPI contract
|
- **`@openreader/compute-worker`** — standalone Node.js worker service
|
||||||
|
- HTTP server: [Fastify](https://fastify.dev/) v5
|
||||||
- Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`)
|
- Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`)
|
||||||
- Storage: AWS SDK v3 S3 client for reading/writing blobs
|
- Storage: AWS SDK v3 S3 client for reading/writing blobs
|
||||||
- Logging: [Pino](https://getpino.io/)
|
- Logging: [Pino](https://getpino.io/)
|
||||||
- Validation: [Zod](https://zod.dev/)
|
- Validation: [Zod](https://zod.dev/)
|
||||||
- The Next.js app communicates with the worker only through the versioned HTTP API generated from OpenAPI.
|
- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS)
|
||||||
|
|
||||||
## Tooling and testing
|
## Tooling and testing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,9 @@ const sidebars: SidebarsConfig = {
|
||||||
'configure/tts-provider-guides/kokoro-fastapi',
|
'configure/tts-provider-guides/kokoro-fastapi',
|
||||||
'configure/tts-provider-guides/kitten-tts-fastapi',
|
'configure/tts-provider-guides/kitten-tts-fastapi',
|
||||||
'configure/tts-provider-guides/orpheus-fastapi',
|
'configure/tts-provider-guides/orpheus-fastapi',
|
||||||
'configure/tts-provider-guides/supertonic',
|
|
||||||
'configure/tts-provider-guides/replicate',
|
'configure/tts-provider-guides/replicate',
|
||||||
'configure/tts-provider-guides/deepinfra',
|
'configure/tts-provider-guides/deepinfra',
|
||||||
'configure/tts-provider-guides/openai',
|
'configure/tts-provider-guides/openai',
|
||||||
'configure/tts-provider-guides/speech-sdk',
|
|
||||||
'configure/tts-provider-guides/other',
|
'configure/tts-provider-guides/other',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -55,12 +53,7 @@ const sidebars: SidebarsConfig = {
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
label: '🚀 Deploy',
|
label: '🚀 Deploy',
|
||||||
items: [
|
items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'],
|
||||||
'deploy/local-development',
|
|
||||||
'deploy/docker-compose',
|
|
||||||
'deploy/compute-worker',
|
|
||||||
'deploy/vercel-deployment',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
title: Acknowledgements
|
|
||||||
---
|
|
||||||
|
|
||||||
This project is built with support from the following open-source projects and tools:
|
|
||||||
|
|
||||||
- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M)
|
|
||||||
- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
|
|
||||||
- [Better Auth](https://www.better-auth.com/)
|
|
||||||
- [SQLite](https://www.sqlite.org/)
|
|
||||||
- [PostgreSQL](https://www.postgresql.org/)
|
|
||||||
- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
|
|
||||||
- [OpenAI Whisper](https://github.com/openai/whisper)
|
|
||||||
- [ffmpeg](https://ffmpeg.org)
|
|
||||||
- [react-pdf](https://github.com/wojtekmaj/react-pdf)
|
|
||||||
- [react-reader](https://github.com/happyr/react-reader)
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
---
|
|
||||||
title: License
|
|
||||||
---
|
|
||||||
|
|
||||||
OpenReader is licensed under the MIT License.
|
|
||||||
|
|
||||||
- Repository license file: [LICENSE](https://github.com/richardr1126/openreader/blob/main/LICENSE)
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
title: Support and Contributing
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature requests
|
|
||||||
|
|
||||||
Use [GitHub Discussions](https://github.com/richardr1126/openreader/discussions) for feature requests and ideas.
|
|
||||||
|
|
||||||
## Issues and support
|
|
||||||
|
|
||||||
If you encounter a bug, open an issue in [GitHub Issues](https://github.com/richardr1126/openreader/issues).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome.
|
|
||||||
|
|
||||||
- Fork the repository
|
|
||||||
- Create your branch
|
|
||||||
- Open a pull request with your changes
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
---
|
|
||||||
title: Admin Panel
|
|
||||||
---
|
|
||||||
|
|
||||||
The admin panel lets a designated set of users manage **shared TTS providers** and **site-wide feature flags** directly from the Settings modal — without touching env vars or redeploying.
|
|
||||||
|
|
||||||
It is gated behind authentication, so you must have auth enabled to use it ([Auth](./auth)).
|
|
||||||
|
|
||||||
## Designating admins
|
|
||||||
|
|
||||||
Set `ADMIN_EMAILS` to a comma-separated list of emails:
|
|
||||||
|
|
||||||
```env
|
|
||||||
AUTH_SECRET=... # required for auth
|
|
||||||
BASE_URL=... # required for auth
|
|
||||||
ADMIN_EMAILS=alice@example.com,bob@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
On every session resolution the server compares the user's email against this list and writes `user.is_admin = true` (or `false` for emails removed from the list). No restart is required to demote — the next page load picks it up.
|
|
||||||
|
|
||||||
When the logged-in user is an admin, an **Admin** tab appears in **Settings → sidebar** with two sub-tabs:
|
|
||||||
|
|
||||||
- **Shared providers** — server-side TTS provider instances visible to all users.
|
|
||||||
- **Site features** — runtime-editable replacements for what were previously build-time public env flags.
|
|
||||||
|
|
||||||
## Shared TTS providers
|
|
||||||
|
|
||||||
Each shared provider is one named instance bound to one of the four built-in provider types (`custom-openai`, `openai`, `replicate`, `deepinfra`). The admin form has:
|
|
||||||
|
|
||||||
| Field | Notes |
|
|
||||||
| --- | --- |
|
|
||||||
| **Slug** | URL-safe identifier exposed to users (e.g. `kokoro-prod`). Must not collide with a built-in id. Lowercase alphanumeric + hyphens. |
|
|
||||||
| **Display name** | Shown in the user's provider dropdown, suffixed with "(shared)". |
|
|
||||||
| **Provider type** | One of the four built-ins. Determines voice/model resolution. |
|
|
||||||
| **Base URL** | Optional. Falls through to the provider type's default when blank. |
|
|
||||||
| **API key** | Encrypted at rest with AES-256-GCM (key derived from `AUTH_SECRET` via scrypt). On edit, leave blank to keep the existing key. |
|
|
||||||
| **Default model** | Optional. Used as the initial model when a user selects this provider. |
|
|
||||||
| **Enabled** | Toggle to hide the provider from non-admin users without deleting it. |
|
|
||||||
|
|
||||||
When a non-admin user picks a shared provider in **Settings → TTS Provider**:
|
|
||||||
|
|
||||||
- The API key / base URL fields are hidden — those credentials never leave the server.
|
|
||||||
- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
|
|
||||||
- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
|
|
||||||
|
|
||||||
Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:
|
|
||||||
|
|
||||||
- `true`: users are restricted to shared providers only.
|
|
||||||
- `false`: users may also use per-user BYOK credentials for built-in providers.
|
|
||||||
|
|
||||||
### Auto-seeded "default-openai"
|
|
||||||
|
|
||||||
On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with:
|
|
||||||
|
|
||||||
- slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai`
|
|
||||||
- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted)
|
|
||||||
- defaultModel set to `kokoro` (you can edit it in Admin → Shared providers)
|
|
||||||
|
|
||||||
After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient.
|
|
||||||
|
|
||||||
:::warning Upgrading from v2.2.0
|
|
||||||
In v2.2.0 and earlier, `API_KEY` / `API_BASE` were read live by the TTS routes on every request. As of v3.0.0 they are **one-shot seeds** consumed only on the first boot where `admin_providers` is empty. After upgrading, boot the app once and confirm a `default-openai` row exists in **Settings → Admin → Shared providers** with the correct base URL. If it is missing or wrong (e.g. the env vars were not set on first boot, or the table was already non-empty from a pre-release), create or edit the shared provider manually — TTS will not fall back to the env vars.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Site features
|
|
||||||
|
|
||||||
Runtime-editable settings, one row per key:
|
|
||||||
|
|
||||||
| Key | What it controls |
|
|
||||||
| --- | --- |
|
|
||||||
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
|
|
||||||
| `changelogFeedUrl` | Public changelog manifest URL used by the Settings modal changelog panel. |
|
|
||||||
| `enableUserSignups` | Controls whether new accounts can be created. Existing accounts can still sign in when this is `false`. |
|
|
||||||
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
|
|
||||||
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
|
|
||||||
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
|
|
||||||
| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
|
|
||||||
| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
|
|
||||||
|
|
||||||
Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag.
|
|
||||||
|
|
||||||
Each row shows a source badge:
|
|
||||||
|
|
||||||
- **from seed** — the value was seeded on first boot (from `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`).
|
|
||||||
- **admin** — explicit admin override. Use **Reset** on the row to clear it back to built-in default behavior.
|
|
||||||
- **default** — no seed/admin row exists; built-in default is active.
|
|
||||||
|
|
||||||
:::warning Security note for `restrictUserApiKeys`
|
|
||||||
Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through this server. Use this only for trusted/self-hosted deployments where that tradeoff is acceptable.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Rate limiting
|
|
||||||
|
|
||||||
A dedicated **Rate limiting** group (within the same admin panel) collects the daily quotas, the PDF parsing throttle, and the upload size cap:
|
|
||||||
|
|
||||||
| Key | What it controls |
|
|
||||||
| --- | --- |
|
|
||||||
| `disableTtsRateLimit` | Disable the per-user/IP daily TTS character limits. When `false`, the daily-limit fields below it apply. |
|
|
||||||
| `disableComputeRateLimit` | Disable per-user PDF parsing rate limiting. When `false`, the burst/sustained limit fields below it apply. |
|
|
||||||
| `maxUploadMb` | Maximum size (MB) accepted for a single document upload. Enforced server-side and signed into the presigned S3 PUT. |
|
|
||||||
|
|
||||||
The **Disable TTS daily rate limiting** and **Disable PDF parsing rate limiting** toggles each reveal a collapsible group of numeric inputs when set to `false`:
|
|
||||||
|
|
||||||
- TTS: anonymous/authenticated per-user daily limits and anonymous/authenticated IP daily backstops.
|
|
||||||
- PDF parsing: burst limit + window (seconds) and sustained limit + window (seconds). The sustained window doubles as a concurrency cap.
|
|
||||||
|
|
||||||
## TTS upstream
|
|
||||||
|
|
||||||
At the end of the **Site features** tab, a dedicated **TTS upstream** group controls server-side request and cache tuning (DB-backed runtime settings, not env vars):
|
|
||||||
|
|
||||||
| Key | What it controls |
|
|
||||||
| --- | --- |
|
|
||||||
| `ttsUpstreamMaxRetries` | Maximum retry attempts for upstream TTS 429/5xx responses. |
|
|
||||||
| `ttsUpstreamTimeoutMs` | Upstream request timeout for OpenAI-compatible TTS calls. |
|
|
||||||
| `ttsCacheMaxSizeBytes` | Maximum size of the in-memory TTS audio cache. |
|
|
||||||
| `ttsCacheTtlMs` | Time-to-live for cached TTS audio buffers. |
|
|
||||||
|
|
||||||
In v4 these settings are admin-only and are no longer configurable through environment variables.
|
|
||||||
|
|
||||||
## Migrating off env vars
|
|
||||||
|
|
||||||
In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:
|
|
||||||
|
|
||||||
1. Deploy this version with your existing env values in place.
|
|
||||||
2. Boot the app once. Open Settings → Admin and verify:
|
|
||||||
- Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
|
|
||||||
- A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
|
|
||||||
3. Remove any bootstrap env vars you no longer need from `.env`.
|
|
||||||
4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
|
|
||||||
|
|
||||||
You can keep `API_BASE` / `API_KEY` if you intentionally want bootstrap fallback behavior on empty provider tables.
|
|
||||||
|
|
||||||
## How keys are protected
|
|
||||||
|
|
||||||
- API keys are encrypted in the `admin_providers` table with AES-256-GCM. The encryption key is derived from `AUTH_SECRET` via `scrypt`.
|
|
||||||
- The masked-list view (`GET /api/admin/providers`, used by the admin UI itself) returns `••••` + last-4 only — never plaintext or ciphertext.
|
|
||||||
- The public list endpoint (`GET /api/tts/shared-providers`, called by every user's browser) returns only `{ slug, displayName, providerType, defaultModel }`. Keys and base URLs are never exposed to the client.
|
|
||||||
- Non-admin users cannot enumerate admin providers' credentials or base URLs through any API.
|
|
||||||
|
|
||||||
:::danger Rotating `AUTH_SECRET` invalidates all stored admin provider keys
|
|
||||||
Because the encryption key for `admin_providers` is derived from `AUTH_SECRET`, changing `AUTH_SECRET` makes every stored API key undecryptable. After rotating it, shared providers will fail to authenticate upstream until you re-enter each provider's API key in **Settings → Admin → Shared providers** (edit the row and paste the key again). There is no automated re-encryption path. If you must rotate `AUTH_SECRET`, plan to re-enter admin provider keys immediately afterward.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Auth](./auth) — required to use the admin panel.
|
|
||||||
- [TTS Providers](./tts-providers) — built-in provider catalog and per-user behavior.
|
|
||||||
- [Environment Variables](../reference/environment-variables) — `ADMIN_EMAILS`, provider bootstrap vars, and runtime JSON seed.
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
---
|
|
||||||
title: Auth
|
|
||||||
---
|
|
||||||
|
|
||||||
This page covers application-level configuration for provider access and authentication.
|
|
||||||
|
|
||||||
## Auth behavior
|
|
||||||
|
|
||||||
- `BASE_URL` and `AUTH_SECRET` are required at startup in v4+.
|
|
||||||
- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
|
|
||||||
- Anonymous auth sessions are disabled by default.
|
|
||||||
- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
|
|
||||||
|
|
||||||
## Runtime modes
|
|
||||||
|
|
||||||
OpenReader has two common runtime modes:
|
|
||||||
|
|
||||||
- **Auth enabled, non-admin user**: user account/session features are available, but no admin controls.
|
|
||||||
- **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features).
|
|
||||||
|
|
||||||
## Admin role
|
|
||||||
|
|
||||||
You can designate one or more users as admins via the `ADMIN_EMAILS` env var:
|
|
||||||
|
|
||||||
```env
|
|
||||||
ADMIN_EMAILS=alice@example.com,bob@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Admins see a new **Admin** tab in **Settings** with two sub-tabs:
|
|
||||||
|
|
||||||
- **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users.
|
|
||||||
- **Site features** — runtime overrides for what were previously build-time public env flags (including account signup availability, default TTS provider, audiobook export, etc.).
|
|
||||||
|
|
||||||
Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference.
|
|
||||||
|
|
||||||
## Route behavior
|
|
||||||
|
|
||||||
- `/` is a public landing/onboarding page and remains indexable.
|
|
||||||
- `/app` is the protected app home (document list and uploader UI).
|
|
||||||
- If a valid session exists (including anonymous), visiting `/` redirects to `/app`.
|
|
||||||
- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`.
|
|
||||||
|
|
||||||
## Related docs
|
|
||||||
|
|
||||||
- For auth environment variables: [Environment Variables](../reference/environment-variables#auth-and-identity)
|
|
||||||
- For admin role and shared TTS provider config: [Admin Panel](./admin-panel)
|
|
||||||
- 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](./object-blob-storage)
|
|
||||||
- For database mode: [Database](./database)
|
|
||||||
- For migration behavior and commands: [Migrations](./migrations)
|
|
||||||
|
|
||||||
## Sync notes
|
|
||||||
|
|
||||||
### Auth enabled
|
|
||||||
|
|
||||||
- Settings and reading progress are saved to the server.
|
|
||||||
- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
|
|
||||||
- If two devices change the same item around the same time, the newest update wins.
|
|
||||||
|
|
||||||
## Claim modal note
|
|
||||||
|
|
||||||
- You may still see old anonymous settings/progress available to claim from older deployments.
|
|
||||||
- Legacy `unclaimed` data is only surfaced through the claim flow; normal authenticated routes are scoped to your current user id.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
---
|
|
||||||
title: Database
|
|
||||||
---
|
|
||||||
|
|
||||||
This page covers database mode selection for OpenReader.
|
|
||||||
|
|
||||||
## Scope of this page
|
|
||||||
|
|
||||||
- Focus: SQL metadata, state, and relational tables.
|
|
||||||
- Not covered here: object key layout and blob transport details (see [Object / Blob Storage](./object-blob-storage)).
|
|
||||||
|
|
||||||
## Database mode
|
|
||||||
|
|
||||||
- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups.
|
|
||||||
- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments.
|
|
||||||
|
|
||||||
## What the database stores
|
|
||||||
|
|
||||||
- Document and audiobook metadata/state used by server routes.
|
|
||||||
- Auth/session tables (`user`, `session`, `account`, `verification`) when auth is enabled — schema is auto-generated by Better Auth.
|
|
||||||
- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
|
|
||||||
- User settings preferences (`user_preferences`) when auth is enabled.
|
|
||||||
- User reading progress (`user_document_progress`) when auth is enabled.
|
|
||||||
- Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails.
|
|
||||||
- TTS segment metadata (`tts_segments`) for server-side playback caching:
|
|
||||||
- Segment identity + settings hash
|
|
||||||
- Audio object key and duration
|
|
||||||
- Optional alignment payload for word highlighting
|
|
||||||
- Status/error state
|
|
||||||
- Text fingerprint/hash (plaintext segment text is not stored)
|
|
||||||
|
|
||||||
App-specific tables are manually maintained in Drizzle schema files, while auth tables are generated by the Better Auth CLI. Both are migrated together via Drizzle. See [Migrations](./migrations) for details.
|
|
||||||
|
|
||||||
## What the database does not store
|
|
||||||
|
|
||||||
- Raw document file bytes
|
|
||||||
- Audiobook audio bytes
|
|
||||||
- TTS segment audio bytes
|
|
||||||
- Generated preview image bytes
|
|
||||||
|
|
||||||
Those payloads live in object storage. SQL stores the metadata, references, and status.
|
|
||||||
|
|
||||||
## Related variables
|
|
||||||
|
|
||||||
- `POSTGRES_URL`
|
|
||||||
|
|
||||||
For database variable behavior, see [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
|
|
||||||
|
|
||||||
## Related docs
|
|
||||||
|
|
||||||
- [Migrations](./migrations)
|
|
||||||
- [Object / Blob Storage](./object-blob-storage)
|
|
||||||
- [Auth](./auth)
|
|
||||||
|
|
||||||
## State sync summary
|
|
||||||
|
|
||||||
- Settings and reading progress are stored in SQL and synced from the app.
|
|
||||||
- Sync is currently request-based (not realtime push invalidation).
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
---
|
|
||||||
title: Migrations
|
|
||||||
---
|
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
This page covers migration behavior for both database schema and storage data in OpenReader.
|
|
||||||
|
|
||||||
## Startup migration behavior
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
:::info
|
|
||||||
In most setups, you do not need to run migration commands manually because startup handles this automatically.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Schema history
|
|
||||||
|
|
||||||
Migrations are applied in order. All of the following ship in v3.0.0; an instance upgrading from v2.2.0 applies `0001`–`0004` in a single startup pass.
|
|
||||||
|
|
||||||
| Migration | Dialects | What it does |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `0001_tts_segments` | SQLite + Postgres | Creates the original single-table `tts_segments` used by server-side TTS segment caching. |
|
|
||||||
| `0002_add_segment_key_to_tts_segments` | SQLite + Postgres | Adds the `segment_key` column to `tts_segments` for stable locator-independent segment identity. |
|
|
||||||
| `0003_tts_segments_v2_split` | SQLite + Postgres | Replaces `tts_segments` with a normalized two-table model: `tts_segment_entries` (one row per document segment + locator identity) and `tts_segment_variants` (one row per settings combination, holding the cached audio key, status, and alignment). Drops the original `tts_segments` table — no released build (v2.2.0 or earlier) ever populated it, so there is no production data to migrate. |
|
|
||||||
| `0004_admin_panel` | SQLite + Postgres | Creates `admin_providers` (encrypted shared TTS provider rows) and `admin_settings` (runtime site-feature config), and adds the `is_admin` column to the `user` table. Backs the [Admin Panel](./admin-panel). |
|
|
||||||
|
|
||||||
To skip automatic startup migrations:
|
|
||||||
|
|
||||||
- Set `RUN_DRIZZLE_MIGRATIONS=false`
|
|
||||||
- Set `RUN_FS_MIGRATIONS=false`
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
If you disable startup migrations, ensure your deployment process runs migrations before serving traffic.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Apply migrations
|
|
||||||
|
|
||||||
In most cases, you do not need manual migration commands because startup runs migrations automatically.
|
|
||||||
|
|
||||||
`pnpm migrate` applies migrations for one database target:
|
|
||||||
|
|
||||||
- Postgres when `POSTGRES_URL` is set
|
|
||||||
- SQLite when `POSTGRES_URL` is unset
|
|
||||||
|
|
||||||
You can always override the target explicitly with `--config`.
|
|
||||||
|
|
||||||
<Tabs groupId="apply-migration-commands">
|
|
||||||
<TabItem value="project-scripts" label="Project Scripts" default>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run pending migrations for one target:
|
|
||||||
# - Postgres if POSTGRES_URL is set
|
|
||||||
# - SQLite if POSTGRES_URL is unset
|
|
||||||
pnpm migrate
|
|
||||||
|
|
||||||
# Run storage migration (filesystem -> S3 + DB)
|
|
||||||
pnpm migrate-fs
|
|
||||||
|
|
||||||
# Dry-run storage migration without uploading/deleting
|
|
||||||
pnpm migrate-fs:dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Migrate SQLite
|
|
||||||
pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
|
|
||||||
|
|
||||||
# Migrate Postgres
|
|
||||||
pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Generate migrations
|
|
||||||
|
|
||||||
`pnpm generate` is a two-phase script for contributors and schema changes:
|
|
||||||
|
|
||||||
1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
|
|
||||||
2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
|
|
||||||
|
|
||||||
:::note
|
|
||||||
Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### Schema ownership
|
|
||||||
|
|
||||||
Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
|
|
||||||
|
|
||||||
- `src/db/schema_auth_sqlite.ts`
|
|
||||||
- `src/db/schema_auth_postgres.ts`
|
|
||||||
|
|
||||||
App-specific tables are manually maintained in the standard Drizzle schema files:
|
|
||||||
|
|
||||||
- `src/db/schema_sqlite.ts`
|
|
||||||
- `src/db/schema_postgres.ts`
|
|
||||||
|
|
||||||
Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
|
|
||||||
|
|
||||||
When app schema changes (for example `tts_segments`), keep these in sync:
|
|
||||||
|
|
||||||
- `src/db/schema_sqlite.ts`
|
|
||||||
- `src/db/schema_postgres.ts`
|
|
||||||
- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
|
|
||||||
- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
|
|
||||||
|
|
||||||
<Tabs groupId="generate-migration-commands">
|
|
||||||
<TabItem value="project-script" label="Project Script" default>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
|
|
||||||
pnpm generate
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="drizzle-direct" label="Manual Drizzle Cmd">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate SQLite migrations only (skips Better Auth CLI)
|
|
||||||
pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
|
|
||||||
|
|
||||||
# Generate Postgres migrations only (skips Better Auth CLI)
|
|
||||||
pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first.
|
|
||||||
:::
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Related docs
|
|
||||||
|
|
||||||
- [Database](./database)
|
|
||||||
- [Object / Blob Storage](./object-blob-storage)
|
|
||||||
- [Migration Environment Variables](../reference/environment-variables#migration-controls)
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
---
|
|
||||||
title: Object / Blob Storage
|
|
||||||
---
|
|
||||||
|
|
||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
This page documents storage backends, blob upload routing, and core Docker mount behavior.
|
|
||||||
|
|
||||||
## Scope of this page
|
|
||||||
|
|
||||||
- Focus: object/blob backends, keyspaces, upload/read paths, and storage debugging.
|
|
||||||
- Not covered here: relational metadata tables and SQL state modeling (see [Database](./database)).
|
|
||||||
|
|
||||||
## Storage backends
|
|
||||||
|
|
||||||
- Embedded (default): embedded SeaweedFS (`weed mini`) blob storage.
|
|
||||||
- External: external S3-compatible object storage.
|
|
||||||
|
|
||||||
Metadata database mode (SQLite vs Postgres) is configured separately in [Database](./database).
|
|
||||||
|
|
||||||
:::warning SeaweedFS Compatibility Note (April 16, 2026)
|
|
||||||
OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds.
|
|
||||||
`4.19` introduced intermittent `InternalError` responses on S3 `PutObject` in our upload flow.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Storage variables are documented in [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
- `3003`: OpenReader app and API routes
|
|
||||||
- `8333`: Embedded SeaweedFS S3 endpoint for direct browser blob access
|
|
||||||
|
|
||||||
:::info
|
|
||||||
`8333` is only needed for direct browser presigned access to embedded SeaweedFS.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Upload behavior
|
|
||||||
|
|
||||||
- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`.
|
|
||||||
- Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable.
|
|
||||||
- Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route).
|
|
||||||
- Preview path: `/api/documents/blob/preview` (returns `202` while a preview is generating; serves/redirects when ready).
|
|
||||||
|
|
||||||
## Document previews
|
|
||||||
|
|
||||||
- PDF/EPUB previews are generated server-side and stored in object storage under `document_previews_v1`.
|
|
||||||
- Preview generation is triggered on upload registration and also backfills on first preview request for older docs.
|
|
||||||
- Preview artifacts are temporary-cache friendly and can be regenerated from the source document blob.
|
|
||||||
|
|
||||||
## FS / Volume Mounts
|
|
||||||
|
|
||||||
### App data mount
|
|
||||||
|
|
||||||
- Target: `/app/docstore`
|
|
||||||
- Recommended: yes, for persistence
|
|
||||||
- Purpose: persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state
|
|
||||||
- Mount string: `-v openreader_docstore:/app/docstore`
|
|
||||||
|
|
||||||
### Library source mount (optional)
|
|
||||||
|
|
||||||
- Target: `/app/docstore/library`
|
|
||||||
- Recommended: optional, use read-only (`:ro`)
|
|
||||||
- Purpose: exposes host files as a source for server library import
|
|
||||||
- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
|
|
||||||
- Details: [Server Library Import](./server-library-import)
|
|
||||||
|
|
||||||
## Private blob endpoint mode
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
Without `8333`, expect higher app-server traffic because uploads/downloads go through API routes instead of direct object endpoint access.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Audiobook Storage Debug Commands
|
|
||||||
|
|
||||||
Audiobook assets are stored in object storage under the `audiobooks_v1` keyspace. Use these commands to inspect and download objects for debugging.
|
|
||||||
|
|
||||||
<Tabs groupId="audiobook-storage-access-cli">
|
|
||||||
<TabItem value="aws-s3" label="AWS S3" default>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all audiobook objects
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive
|
|
||||||
|
|
||||||
# Filter to one book id (replace <book-id>)
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive | grep "<book-id>-audiobook/"
|
|
||||||
|
|
||||||
# Download one object by full key
|
|
||||||
aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/<path>/<file>.m4b" "./audiobook.m4b"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="s3-compatible" label="Embedded / MinIO / R2 / etc">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all audiobook objects
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT"
|
|
||||||
|
|
||||||
# Filter to one book id (replace <book-id>)
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "<book-id>-audiobook/"
|
|
||||||
|
|
||||||
# Download one object by full key
|
|
||||||
aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/<path>/<file>.m4b" "./audiobook.m4b" --endpoint-url "$S3_ENDPOINT"
|
|
||||||
```
|
|
||||||
|
|
||||||
Embedded default example: `S3_ENDPOINT=http://127.0.0.1:8333` (or your mapped host/port).
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## TTS Segment Storage
|
|
||||||
|
|
||||||
Server-side TTS segment audio is stored in object storage under the `tts_segments_v1` keyspace.
|
|
||||||
|
|
||||||
Typical key layout:
|
|
||||||
|
|
||||||
- `${S3_PREFIX}/tts_segments_v1/users/<url-encoded-user-id>/docs/<document-id>/<document-version>/<settings-hash>/<segment-id>.mp3`
|
|
||||||
- `${S3_PREFIX}/tts_segments_v1/ns/<test-namespace>/users/<url-encoded-user-id>/docs/...` (test namespace mode)
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- For the corresponding SQL metadata model (`tts_segments`), see [Database](./database).
|
|
||||||
|
|
||||||
## Account Deletion Cleanup
|
|
||||||
|
|
||||||
Account deletion performs best-effort object cleanup:
|
|
||||||
|
|
||||||
- Document blobs + preview artifacts
|
|
||||||
- Audiobook blobs
|
|
||||||
- TTS segment blobs under `tts_segments_v1`
|
|
||||||
|
|
||||||
If object deletion fails, account deletion still proceeds and orphaned objects may require manual cleanup.
|
|
||||||
|
|
||||||
## TTS Segment Storage Debug Commands
|
|
||||||
|
|
||||||
Use these commands to inspect segment objects.
|
|
||||||
|
|
||||||
<Tabs groupId="tts-segment-storage-access-cli">
|
|
||||||
<TabItem value="aws-s3" label="AWS S3" default>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all TTS segment objects
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive
|
|
||||||
|
|
||||||
# Filter to one document id (replace <document-id>)
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive | grep "/docs/<document-id>/"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="s3-compatible" label="Embedded / MinIO / R2 / etc">
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all TTS segment objects
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive --endpoint-url "$S3_ENDPOINT"
|
|
||||||
|
|
||||||
# Filter to one document id (replace <document-id>)
|
|
||||||
aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "/docs/<document-id>/"
|
|
||||||
```
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
---
|
|
||||||
title: Server Library Import
|
|
||||||
---
|
|
||||||
|
|
||||||
This page documents how server library import works and how to configure it.
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
Server library import lets you browse files from one or more server directories and import selected files into OpenReader.
|
|
||||||
|
|
||||||
- Import is user-driven via a selection modal
|
|
||||||
- Only selected files are imported
|
|
||||||
- Imported files become normal OpenReader documents
|
|
||||||
|
|
||||||
## FS / Volume Mounts
|
|
||||||
|
|
||||||
### App data mount
|
|
||||||
|
|
||||||
- Target: `/app/docstore`
|
|
||||||
- Recommended: yes, for persistence
|
|
||||||
- Purpose: stores app runtime data, metadata DB, and embedded storage state
|
|
||||||
- Mount string: `-v openreader_docstore:/app/docstore`
|
|
||||||
|
|
||||||
### Library source mount
|
|
||||||
|
|
||||||
- Target: `/app/docstore/library`
|
|
||||||
- Recommended: yes for this feature, use read-only (`:ro`)
|
|
||||||
- Purpose: exposes host files as import candidates in Server Library Import
|
|
||||||
- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
|
|
||||||
|
|
||||||
## Import flow
|
|
||||||
|
|
||||||
1. Open **Settings -> Documents -> Server Library Import**.
|
|
||||||
2. Select files in the modal.
|
|
||||||
3. Click **Import**.
|
|
||||||
|
|
||||||
Selected files are fetched from the server library endpoint and imported into OpenReader storage.
|
|
||||||
|
|
||||||
:::warning Shared Library Roots
|
|
||||||
Library roots are configured at the server level (not per-user). Any user who can access Server Library Import can browse/import from the same configured roots.
|
|
||||||
|
|
||||||
Imported documents are still saved to the importing user's document scope.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Supported file types
|
|
||||||
|
|
||||||
- `.pdf`
|
|
||||||
- `.epub`
|
|
||||||
- `.html`, `.htm`
|
|
||||||
- `.txt`
|
|
||||||
- `.md`, `.mdown`, `.markdown`
|
|
||||||
|
|
||||||
## Optional: Configure Library Roots
|
|
||||||
|
|
||||||
You only need this when the default mounted path is not what you want.
|
|
||||||
|
|
||||||
By default, OpenReader uses `docstore/library` as the import root. You can override that with environment variables:
|
|
||||||
|
|
||||||
- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon
|
|
||||||
- `IMPORT_LIBRARY_DIR`: single root
|
|
||||||
|
|
||||||
See [Environment Variables](../reference/environment-variables#library-import) for variable details.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Library listing is capped per request (up to 10,000 files).
|
|
||||||
- When auth is enabled, library import endpoints require a valid session.
|
|
||||||
- The mounted library is a source; removing it does not delete already imported documents.
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
title: DeepInfra
|
|
||||||
---
|
|
||||||
|
|
||||||
Use DeepInfra's hosted TTS models as your provider.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
**Recommended (auth + admin): Settings → Admin → Shared providers**
|
|
||||||
|
|
||||||
1. Add a shared provider with type `deepinfra`.
|
|
||||||
2. Keep base URL as `https://api.deepinfra.com/v1/openai`.
|
|
||||||
3. Enter your API key.
|
|
||||||
4. Set your preferred default model/voice.
|
|
||||||
|
|
||||||
**Legacy bootstrap seed (optional, first boot only):**
|
|
||||||
|
|
||||||
```env
|
|
||||||
API_BASE=https://api.deepinfra.com/v1/openai
|
|
||||||
API_KEY=your-deepinfra-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
|
|
||||||
|
|
||||||
1. Set provider to `Deepinfra`.
|
|
||||||
2. The base URL is pre-filled, no changes needed.
|
|
||||||
3. Enter your `API_KEY`.
|
|
||||||
4. Choose a model and voice.
|
|
||||||
|
|
||||||
See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Available models include `hexgrad/Kokoro-82M` and `canopylabs/orpheus-3b-0.1-ft`.
|
|
||||||
- Without an API key, only the free-tier model (`hexgrad/Kokoro-82M`) is shown in the dropdown.
|
|
||||||
- TTS requests are sent from the server, not the browser. The API key is never exposed to clients.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [DeepInfra TTS models](https://deepinfra.com/models/text-to-speech)
|
|
||||||
- [TTS Providers](../tts-providers)
|
|
||||||
- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue