From 992224f2e86eaba85947cb28be078991ac210623 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:44:37 +0000 Subject: [PATCH] feat(books): add read-aloud generation Generate a synced read-aloud EPUB from a book's EPUB and audiobook through an optional STAlign worker (Docker on CPU, or native with MLX on Apple Silicon), with review, re-sync, replace in place, restart recovery and live progress on the Files tab. Book details also stack the ebook and audiobook covers when both exist. See docs/READ_ALOUD.md. --- .env.example | 17 + .github/actions/setup-node-pnpm/action.yml | 4 + .github/workflows/ci.yml | 33 + .github/workflows/release.yml | 16 + README.md | 1 + client/src/assets/theme/bridge.css | 2 + client/src/assets/theme/tokens.css | 8 + .../admin/__tests__/UserFormDrawer.spec.ts | 3 +- .../features/admin/lib/permission-presets.ts | 2 +- .../components/FilterFormatPicker.spec.ts | 2 +- .../components/__tests__/BookListRow.spec.ts | 2 +- .../__tests__/BookTableCells.spec.ts | 2 +- .../components/__tests__/DetailsTab.spec.ts | 8 +- .../detail/details/BookCoverStack.vue | 217 + .../details/__tests__/BookCoverStack.spec.ts | 88 + .../components/detail/files/FilesHeroBar.vue | 277 +- .../files/__tests__/FilesHeroBar.spec.ts | 96 + .../components/detail/tabs/DetailsTab.vue | 71 +- .../book/components/detail/tabs/FilesTab.vue | 198 +- .../detail/tabs/__tests__/DetailsTab.spec.ts | 98 +- .../tabs/__tests__/DetailsTabActions.spec.ts | 2 +- .../__tests__/useFilterTokens.spec.ts | 2 +- .../book/lib/__tests__/cover-stack.spec.ts | 19 + .../features/book/lib/book-formats.test.ts | 2 +- client/src/features/book/lib/cover-stack.ts | 13 + .../__tests__/LibraryCreatorFileWrite.spec.ts | 6 +- .../__tests__/LibraryCreatorMetadata.spec.ts | 4 +- .../components/NotificationItem.spec.ts | 64 +- .../components/NotificationItem.vue | 14 +- .../NotificationPreferences.spec.ts | 49 +- .../components/NotificationPreferences.vue | 14 +- .../composables/useNotifications.test.ts | 4 +- .../lib/notification-category-groups.ts | 4 +- .../__tests__/ReadAloudDialog.spec.ts | 324 + .../__tests__/ReadAloudJobCard.spec.ts | 371 + .../__tests__/ReadAloudReview.spec.ts | 153 + .../features/read-aloud/__tests__/fixtures.ts | 112 + .../read-aloud/__tests__/read-aloud.spec.ts | 201 + .../components/ReadAloudActivityLog.vue | 74 + .../read-aloud/components/ReadAloudDialog.vue | 552 + .../components/ReadAloudInlineConfirm.vue | 93 + .../components/ReadAloudInviteChip.vue | 25 + .../components/ReadAloudJobCard.vue | 526 + .../read-aloud/components/ReadAloudReview.vue | 349 + .../components/ReadAloudSparkles.vue | 43 + .../components/ReadAloudTrackList.vue | 127 + .../composables/useReadAloud.test.ts | 426 + .../read-aloud/composables/useReadAloud.ts | 407 + .../composables/useReadAloudEvents.ts | 114 + .../composables/useReadAloudInvite.test.ts | 164 + .../composables/useReadAloudInvite.ts | 55 + .../composables/useReadAloudReport.ts | 51 + .../src/features/read-aloud/lib/read-aloud.ts | 374 + client/src/lib/__tests__/socket.spec.ts | 84 +- client/src/lib/socket.ts | 36 +- client/src/locales/en.json | 331 +- client/src/views/BookFilesView.vue | 6 +- docker-compose.yml | 47 + docs/DEVELOPMENT.md | 9 +- docs/READ_ALOUD.md | 176 + package.json | 8 +- packages/types/src/index.ts | 1 + packages/types/src/notification.ts | 9 + packages/types/src/permissions.ts | 3 + packages/types/src/read-aloud.ts | 442 + ...ryteller-platform__ghost-story@0.3.4.patch | 14 + pnpm-lock.yaml | 1444 + pnpm-workspace.yaml | 5 + scripts/e2e/suite-registry.mjs | 22 + server/.env.example | 9 + server/file-env.sh | 1 + server/src/app.module.ts | 4 + server/src/config/config.ts | 11 + server/src/config/docker-env-file.test.ts | 1 + server/src/config/env.validation.test.ts | 55 + server/src/config/env.validation.ts | 29 + .../migrations/0125_add_read_aloud_jobs.sql | 67 + .../0126_add_read_aloud_job_reports.sql | 8 + .../src/db/migrations/meta/0125_snapshot.json | 24803 +++++++++++++++ .../src/db/migrations/meta/0126_snapshot.json | 24851 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 14 + server/src/db/schema/index.ts | 1 + server/src/db/schema/koreader.ts | 5 +- server/src/db/schema/read-aloud.ts | 120 + .../file-write/file-write.repository.ts | 10 +- .../dto/create-read-aloud-job.dto.ts | 29 + .../dto/resync-read-aloud-job.dto.ts | 74 + .../read-aloud/read-aloud-activity.service.ts | 90 + .../modules/read-aloud/read-aloud-errors.ts | 63 + .../read-aloud/read-aloud-event.repository.ts | 44 + .../read-aloud/read-aloud-job.repository.ts | 636 + .../read-aloud-lifecycle.service.test.ts | 72 + .../read-aloud-lifecycle.service.ts | 130 + .../read-aloud-notifier.service.test.ts | 110 + .../read-aloud/read-aloud-notifier.service.ts | 129 + .../read-aloud-presenter.service.ts | 76 + .../read-aloud-publisher.service.test.ts | 109 + .../read-aloud-publisher.service.ts | 125 + .../read-aloud/read-aloud-report.test.ts | 231 + .../modules/read-aloud/read-aloud-report.ts | 185 + .../read-aloud-worker-client.service.test.ts | 94 + .../read-aloud-worker-client.service.ts | 133 + .../read-aloud-worker.service.test.ts | 553 + .../read-aloud/read-aloud-worker.service.ts | 373 + .../read-aloud-workspace.service.ts | 77 + .../read-aloud/read-aloud.constants.ts | 20 + .../read-aloud/read-aloud.controller.ts | 78 + .../read-aloud/read-aloud.gateway.test.ts | 50 + .../modules/read-aloud/read-aloud.gateway.ts | 64 + .../modules/read-aloud/read-aloud.module.ts | 58 + .../read-aloud/read-aloud.service.test.ts | 477 + .../modules/read-aloud/read-aloud.service.ts | 464 + server/src/modules/read-aloud/test-helpers.ts | 164 + .../upload/generated-book-file.repository.ts | 60 + .../generated-book-file.service.test.ts | 358 + .../upload/generated-book-file.service.ts | 394 + .../src/modules/upload/upload.module.test.ts | 5 + server/src/modules/upload/upload.module.ts | 15 +- server/src/modules/upload/upload.service.ts | 226 +- server/test/authorization-matrix.e2e-spec.ts | 15 +- .../route-inventory/manifest.json | 7 +- .../route-inventory/routes-10.json | 120 + .../e2e/read-aloud/fake-read-aloud-worker.ts | 255 + server/test/read-aloud-jobs.e2e-spec.ts | 436 + workers/read-aloud/Dockerfile | 68 + workers/read-aloud/assets/cuda-probe.onnx | Bin 0 -> 90 bytes workers/read-aloud/entrypoint.sh | 7 + workers/read-aloud/package.json | 33 + .../read-aloud/scripts/make-probe-model.mjs | 62 + workers/read-aloud/src/accelerator.ts | 78 + workers/read-aloud/src/config.ts | 102 + workers/read-aloud/src/jobs.ts | 540 + workers/read-aloud/src/log.ts | 60 + workers/read-aloud/src/main.ts | 63 + workers/read-aloud/src/paths.ts | 168 + workers/read-aloud/src/pipeline-contract.ts | 54 + workers/read-aloud/src/pipeline.ts | 230 + workers/read-aloud/src/probe.ts | 21 + workers/read-aloud/src/protocol.ts | 62 + workers/read-aloud/src/runner.ts | 441 + workers/read-aloud/src/server.ts | 182 + workers/read-aloud/src/stages.ts | 208 + workers/read-aloud/src/toolchain.ts | 30 + workers/read-aloud/src/validation.ts | 124 + workers/read-aloud/test/accelerator.test.mjs | 64 + workers/read-aloud/test/config.test.mjs | 85 + .../test/fixtures/fake-pipeline.mjs | 126 + workers/read-aloud/test/helpers.mjs | 128 + workers/read-aloud/test/runner.test.mjs | 114 + workers/read-aloud/test/server.test.mjs | 702 + workers/read-aloud/test/smoke.test.mjs | 221 + .../read-aloud/test/smoke/make-fixture.mjs | 136 + workers/read-aloud/test/stages.test.mjs | 113 + workers/read-aloud/tsconfig.json | 15 + 154 files changed, 69074 insertions(+), 271 deletions(-) create mode 100644 client/src/features/book/components/detail/details/BookCoverStack.vue create mode 100644 client/src/features/book/components/detail/details/__tests__/BookCoverStack.spec.ts create mode 100644 client/src/features/book/components/detail/files/__tests__/FilesHeroBar.spec.ts create mode 100644 client/src/features/book/lib/__tests__/cover-stack.spec.ts create mode 100644 client/src/features/book/lib/cover-stack.ts create mode 100644 client/src/features/read-aloud/__tests__/ReadAloudDialog.spec.ts create mode 100644 client/src/features/read-aloud/__tests__/ReadAloudJobCard.spec.ts create mode 100644 client/src/features/read-aloud/__tests__/ReadAloudReview.spec.ts create mode 100644 client/src/features/read-aloud/__tests__/fixtures.ts create mode 100644 client/src/features/read-aloud/__tests__/read-aloud.spec.ts create mode 100644 client/src/features/read-aloud/components/ReadAloudActivityLog.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudDialog.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudInlineConfirm.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudInviteChip.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudJobCard.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudReview.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudSparkles.vue create mode 100644 client/src/features/read-aloud/components/ReadAloudTrackList.vue create mode 100644 client/src/features/read-aloud/composables/useReadAloud.test.ts create mode 100644 client/src/features/read-aloud/composables/useReadAloud.ts create mode 100644 client/src/features/read-aloud/composables/useReadAloudEvents.ts create mode 100644 client/src/features/read-aloud/composables/useReadAloudInvite.test.ts create mode 100644 client/src/features/read-aloud/composables/useReadAloudInvite.ts create mode 100644 client/src/features/read-aloud/composables/useReadAloudReport.ts create mode 100644 client/src/features/read-aloud/lib/read-aloud.ts create mode 100644 docs/READ_ALOUD.md create mode 100644 packages/types/src/read-aloud.ts create mode 100644 patches/@storyteller-platform__ghost-story@0.3.4.patch create mode 100644 server/src/db/migrations/0125_add_read_aloud_jobs.sql create mode 100644 server/src/db/migrations/0126_add_read_aloud_job_reports.sql create mode 100644 server/src/db/migrations/meta/0125_snapshot.json create mode 100644 server/src/db/migrations/meta/0126_snapshot.json create mode 100644 server/src/db/schema/read-aloud.ts create mode 100644 server/src/modules/read-aloud/dto/create-read-aloud-job.dto.ts create mode 100644 server/src/modules/read-aloud/dto/resync-read-aloud-job.dto.ts create mode 100644 server/src/modules/read-aloud/read-aloud-activity.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-errors.ts create mode 100644 server/src/modules/read-aloud/read-aloud-event.repository.ts create mode 100644 server/src/modules/read-aloud/read-aloud-job.repository.ts create mode 100644 server/src/modules/read-aloud/read-aloud-lifecycle.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-lifecycle.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-notifier.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-notifier.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-presenter.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-publisher.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-publisher.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-report.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-report.ts create mode 100644 server/src/modules/read-aloud/read-aloud-worker-client.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-worker-client.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-worker.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud-worker.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud-workspace.service.ts create mode 100644 server/src/modules/read-aloud/read-aloud.constants.ts create mode 100644 server/src/modules/read-aloud/read-aloud.controller.ts create mode 100644 server/src/modules/read-aloud/read-aloud.gateway.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud.gateway.ts create mode 100644 server/src/modules/read-aloud/read-aloud.module.ts create mode 100644 server/src/modules/read-aloud/read-aloud.service.test.ts create mode 100644 server/src/modules/read-aloud/read-aloud.service.ts create mode 100644 server/src/modules/read-aloud/test-helpers.ts create mode 100644 server/src/modules/upload/generated-book-file.repository.ts create mode 100644 server/src/modules/upload/generated-book-file.service.test.ts create mode 100644 server/src/modules/upload/generated-book-file.service.ts create mode 100644 server/test/e2e/read-aloud/fake-read-aloud-worker.ts create mode 100644 server/test/read-aloud-jobs.e2e-spec.ts create mode 100644 workers/read-aloud/Dockerfile create mode 100644 workers/read-aloud/assets/cuda-probe.onnx create mode 100644 workers/read-aloud/entrypoint.sh create mode 100644 workers/read-aloud/package.json create mode 100644 workers/read-aloud/scripts/make-probe-model.mjs create mode 100644 workers/read-aloud/src/accelerator.ts create mode 100644 workers/read-aloud/src/config.ts create mode 100644 workers/read-aloud/src/jobs.ts create mode 100644 workers/read-aloud/src/log.ts create mode 100644 workers/read-aloud/src/main.ts create mode 100644 workers/read-aloud/src/paths.ts create mode 100644 workers/read-aloud/src/pipeline-contract.ts create mode 100644 workers/read-aloud/src/pipeline.ts create mode 100644 workers/read-aloud/src/probe.ts create mode 100644 workers/read-aloud/src/protocol.ts create mode 100644 workers/read-aloud/src/runner.ts create mode 100644 workers/read-aloud/src/server.ts create mode 100644 workers/read-aloud/src/stages.ts create mode 100644 workers/read-aloud/src/toolchain.ts create mode 100644 workers/read-aloud/src/validation.ts create mode 100644 workers/read-aloud/test/accelerator.test.mjs create mode 100644 workers/read-aloud/test/config.test.mjs create mode 100644 workers/read-aloud/test/fixtures/fake-pipeline.mjs create mode 100644 workers/read-aloud/test/helpers.mjs create mode 100644 workers/read-aloud/test/runner.test.mjs create mode 100644 workers/read-aloud/test/server.test.mjs create mode 100644 workers/read-aloud/test/smoke.test.mjs create mode 100644 workers/read-aloud/test/smoke/make-fixture.mjs create mode 100644 workers/read-aloud/test/stages.test.mjs create mode 100644 workers/read-aloud/tsconfig.json diff --git a/.env.example b/.env.example index 01aa1e97f..20a024930 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,23 @@ APP_URL=https://bookorbit.example.com # Node.js JavaScript heap limit in MB. Raise this for very large libraries, 250K+ books. NODE_MAX_OLD_SPACE_SIZE=2048 +# Optional read-aloud generation. Enable the compose profile and use the same random token for +# BookOrbit and its private STAlign worker. The first job per language downloads a speech model +# (about 190 MB) into data/app/read-aloud. Generate the token with: openssl rand -hex 32 +# COMPOSE_PROFILES=read-aloud +# READ_ALOUD_WORKER_URL=http://read-aloud-worker:8787 +# READ_ALOUD_WORKER_TOKEN= +# READ_ALOUD_WORKER_IMAGE=ghcr.io/bookorbit/bookorbit-read-aloud-worker:latest +# READ_ALOUD_MAX_OUTPUT_BYTES=21474836480 +# Folders the worker can read books from, comma-separated. Add a read-only mount for each extra +# library folder to the worker service as well. +# READ_ALOUD_LIBRARY_ROOTS=/books +# The worker image runs on CPU. A native worker on Apple Silicon uses MLX when this is auto; cuda is +# only for a native Linux worker with CUDA 12 and cuDNN 9, and is checked at startup. +# READ_ALOUD_CTC_DEVICE=auto +# READ_ALOUD_CTC_DTYPE=auto +# READ_ALOUD_CTC_BATCH_SIZE= + # --- Optional overrides (uncomment to set) --- # If you already run your own PostgreSQL, set DATABASE_URL and remove the diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 69af81fc7..1dbf2f97c 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -63,7 +63,11 @@ runs: shell: bash run: pnpm fetch --frozen-lockfile + # onnxruntime-node's postinstall otherwise downloads its CUDA provider on Linux x64. Nothing in CI + # uses it, and the CPU binding the read-aloud worker tests load ships in the npm package itself. - name: Install dependencies if: ${{ inputs.install == 'true' }} shell: bash + env: + ONNXRUNTIME_NODE_INSTALL: skip run: pnpm install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c2154800..37c90b1a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,6 +261,7 @@ jobs: filters: | server: - 'server/**' + - 'workers/**' # Server tests assert against the plugin sources, so plugin-only # changes can break them. - 'koreader-plugin/**' @@ -414,6 +415,7 @@ jobs: run: | if [[ "$PACKAGE" == "server" ]]; then pnpm --filter server type-check + pnpm --filter @bookorbit/read-aloud-worker type-check else pnpm --filter @bookorbit/types build pnpm --filter client type-check @@ -488,6 +490,15 @@ jobs: pnpm --filter client exec vitest --run --coverage --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT" fi + # The worker's control process, runner and resume logic run against a fake pipeline; the real + # STAlign smoke run stays opt-in (READ_ALOUD_SMOKE=1). + - name: Run read-aloud worker tests + if: steps.has-changes.outputs.run == 'true' && matrix.package == 'server' + shell: bash + run: | + pnpm --filter @bookorbit/types build + pnpm --filter @bookorbit/read-aloud-worker test + - name: Upload coverage reports to Codecov if: steps.has-changes.outputs.run == 'true' && env.CODECOV_TOKEN != '' uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 @@ -684,6 +695,28 @@ jobs: ignore-unfixed: true exit-code: "1" + - name: Build read-aloud worker image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: ./workers/read-aloud/Dockerfile + push: false + load: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-read-aloud-worker:${{ steps.pr-meta.outputs.scan_tag }} + platforms: linux/amd64 + cache-from: type=gha,scope=ci-yml-pr-read-aloud-worker + cache-to: type=gha,mode=max,scope=ci-yml-pr-read-aloud-worker + + - name: Scan read-aloud worker image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-read-aloud-worker:${{ steps.pr-meta.outputs.scan_tag }} + format: table + vuln-type: os,library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + pr-image-publish: name: PR Image Publish runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af1c77f62..a884ff133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -355,6 +355,21 @@ jobs: type=gha,scope=container-image-yml-amd64 type=gha,scope=container-image-yml-arm64 + - name: Build and push read-aloud worker image + id: build-read-aloud-worker + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: ./workers/read-aloud/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-read-aloud-worker:${{ needs.release.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-read-aloud-worker:latest + build-args: APP_VERSION=v${{ needs.release.outputs.version }} + cache-from: type=gha,scope=release-read-aloud-worker + cache-to: type=gha,mode=max,scope=release-read-aloud-worker + - name: Enrich release notes shell: bash env: @@ -407,3 +422,4 @@ jobs: echo "- **Version:** \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY" echo "- **APP_VERSION baked:** \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Read-aloud worker:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 5988c6318..0827d9766 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ Full documentation is at **[bookorbit.app](https://bookorbit.app/what-is-bookorb For setting up book requests, see the [book requests guide](docs/BOOK_REQUESTS.md): indexers, download clients, path mappings, automation, and the encryption key they all need. For local development, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To contribute, see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for the full workflow: branch naming, test expectations, PR checklist, and commit format. +For optional STAlign read-aloud generation and QuartzNet hardware acceleration, see [docs/READ_ALOUD.md](docs/READ_ALOUD.md). ## Unsupported Storage Configurations diff --git a/client/src/assets/theme/bridge.css b/client/src/assets/theme/bridge.css index e9646d182..3f15ead1b 100644 --- a/client/src/assets/theme/bridge.css +++ b/client/src/assets/theme/bridge.css @@ -70,6 +70,8 @@ --color-diff-ins: var(--diff-ins); --color-scrim: var(--scrim); --color-scrim-media: var(--scrim-media); + --color-cover-chip: var(--cover-chip); + --color-cover-chip-foreground: var(--cover-chip-foreground); --color-path-dim: var(--path-dim); --color-path-fold: var(--path-fold); diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 046f69ab7..fa0d0a112 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -111,6 +111,11 @@ --scrim: oklch(0.2 0.02 var(--tint-h) / 0.35); --scrim-media: oklch(0 0 0 / 0.82); + /* Chips and buttons laid over cover art. The art can be anything from white to black, so the fill + is dark and mostly opaque in both themes and the ink near-white: above 7:1 even over a white cover. */ + --cover-chip: oklch(0.16 0.01 var(--tint-h) / 0.72); + --cover-chip-foreground: oklch(0.985 0.002 var(--tint-h)); + /* Shadows */ --elevation-xs: 0 1px 2px oklch(0 0 0 / 0.05); --elevation-sm: 0 1px 3px oklch(0 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.06); @@ -438,6 +443,9 @@ /* A dark surface needs a deeper veil than a light one to read as dimmed at all. */ --scrim: oklch(0.06 0.01 var(--tint-h) / 0.62); + /* Deeper on a dark page so the chip does not glow against the darkened card around the cover. */ + --cover-chip: oklch(0.12 0.01 var(--tint-h) / 0.78); + --cover-chip-foreground: oklch(0.97 0.002 var(--tint-h)); /* Shadows */ --elevation-xs: 0 1px 2px oklch(0 0 0 / 0.4); diff --git a/client/src/features/admin/__tests__/UserFormDrawer.spec.ts b/client/src/features/admin/__tests__/UserFormDrawer.spec.ts index c06a28ed7..87e8e2e08 100644 --- a/client/src/features/admin/__tests__/UserFormDrawer.spec.ts +++ b/client/src/features/admin/__tests__/UserFormDrawer.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { Permission } from '@bookorbit/types' import UserFormDrawer from '../UserFormDrawer.vue' +import { grantingPermissions } from '../lib/permission-presets' const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn<(input: string, init?: RequestInit) => Promise>(), @@ -146,7 +147,7 @@ describe('UserFormDrawer permissions', () => { const deleteBooks = wrapper.findAll('label').find((label) => label.text().includes('Delete books')) expect(deleteBooks?.text()).toContain('Manage') - expect(wrapper.text()).toContain('What this account is allowed to do. 2 of 26 granted.') + expect(wrapper.text()).toContain(`What this account is allowed to do. 2 of ${grantingPermissions().length} granted.`) }) it('reports the preset the selection matches', async () => { diff --git a/client/src/features/admin/lib/permission-presets.ts b/client/src/features/admin/lib/permission-presets.ts index 900ba6e8c..8efb7413a 100644 --- a/client/src/features/admin/lib/permission-presets.ts +++ b/client/src/features/admin/lib/permission-presets.ts @@ -15,7 +15,7 @@ export interface PermissionGroup { export const PERMISSION_GROUPS: PermissionGroup[] = [ { id: 'libraryBooks', - use: [Permission.LibraryDownload, Permission.LibraryUpload, Permission.LibraryEditMetadata], + use: [Permission.LibraryDownload, Permission.LibraryUpload, Permission.LibraryEditMetadata, Permission.LibraryGenerateReadAloud], manage: [Permission.LibraryDeleteBooks], }, { diff --git a/client/src/features/book/components/FilterFormatPicker.spec.ts b/client/src/features/book/components/FilterFormatPicker.spec.ts index b3995158b..203bf63c1 100644 --- a/client/src/features/book/components/FilterFormatPicker.spec.ts +++ b/client/src/features/book/components/FilterFormatPicker.spec.ts @@ -7,7 +7,7 @@ describe('FilterFormatPicker', () => { it('offers read-along beside the physical book formats', async () => { const wrapper = mount(FilterFormatPicker, { props: { modelValue: [] } }) const buttons = wrapper.findAll('button') - const readAlong = buttons.find((button) => button.attributes('aria-label') === 'Read-along EPUB') + const readAlong = buttons.find((button) => button.attributes('aria-label') === 'Read-aloud EPUB') expect(buttons).toHaveLength(BOOK_FORMATS.length + 1) expect(readAlong).toBeDefined() diff --git a/client/src/features/book/components/__tests__/BookListRow.spec.ts b/client/src/features/book/components/__tests__/BookListRow.spec.ts index 32b36a995..53b5fbc67 100644 --- a/client/src/features/book/components/__tests__/BookListRow.spec.ts +++ b/client/src/features/book/components/__tests__/BookListRow.spec.ts @@ -236,7 +236,7 @@ describe('BookListRow - present state', () => { expect(wrapper.text()).toContain('EPUB') expect(wrapper.text()).not.toContain('NARR') - expect(wrapper.text()).toContain('Open read-along EPUB') + expect(wrapper.text()).toContain('Open read-aloud EPUB') expect(wrapper.find('.lucide-headphones').exists()).toBe(true) }) diff --git a/client/src/features/book/components/__tests__/BookTableCells.spec.ts b/client/src/features/book/components/__tests__/BookTableCells.spec.ts index 32c77f53f..f63768f12 100644 --- a/client/src/features/book/components/__tests__/BookTableCells.spec.ts +++ b/client/src/features/book/components/__tests__/BookTableCells.spec.ts @@ -163,7 +163,7 @@ describe('BookTableFormatCell', () => { const wrapper = mountFormat([{ id: 1, format: 'epub', role: 'primary', sizeBytes: null, mediaOverlay: { available: true, durationSeconds: 42 } }]) expect(wrapper.text()).toContain('EPUB') expect(wrapper.text()).not.toContain('NARR') - expect(wrapper.text()).toContain('Read-along EPUB') + expect(wrapper.text()).toContain('Read-aloud EPUB') expect(wrapper.find('.lucide-headphones').exists()).toBe(true) }) diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index fda0ff7d5..42d2272ce 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -33,7 +33,7 @@ vi.mock('vue-router', async (importOriginal) => { } }) vi.mock('@/features/auth/composables/usePermissions', () => ({ - usePermissions: () => ({ hasPermission: () => false }), + usePermissions: () => ({ hasPermission: () => false, isDemoRestrictedAccount: { value: false } }), })) vi.mock('@/lib/api', () => ({ api: vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise> }>>(async () => ({ @@ -175,10 +175,10 @@ describe('DetailsTab - present state', () => { it('hands the shared cover lightbox the book, opening on the face', async () => { const book = makeBook({ coverSource: 'extracted', - coverMedia: ['ebook', 'audio'], + coverMedia: ['ebook'], covers: { ebook: { source: 'extracted', updatedAt: '2026-09-01T00:00:00.000Z', width: 600, height: 900 }, - audio: { source: 'custom', updatedAt: '2026-09-02T00:00:00.000Z', width: 800, height: 800 }, + audio: null, }, }) const wrapper = mount(DetailsTab, { props: { book }, global: globalStubs }) @@ -597,7 +597,7 @@ describe('DetailsTab - present state', () => { await flushPromises() - expect(wrapper.text()).toContain('Read-along EPUB') + expect(wrapper.text()).toContain('Read-aloud EPUB') expect(wrapper.text()).toContain('25%') }) diff --git a/client/src/features/book/components/detail/details/BookCoverStack.vue b/client/src/features/book/components/detail/details/BookCoverStack.vue new file mode 100644 index 000000000..b86523c46 --- /dev/null +++ b/client/src/features/book/components/detail/details/BookCoverStack.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/client/src/features/book/components/detail/details/__tests__/BookCoverStack.spec.ts b/client/src/features/book/components/detail/details/__tests__/BookCoverStack.spec.ts new file mode 100644 index 000000000..5fd20b5f0 --- /dev/null +++ b/client/src/features/book/components/detail/details/__tests__/BookCoverStack.spec.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { BookDetail } from '@bookorbit/types' +import BookCoverStack from '../BookCoverStack.vue' + +vi.mock('@/features/book/composables/useCoverVersions', () => ({ + useCoverVersions: () => ({ + coverUrl: (id: number, _type: string, _version: unknown, medium?: string) => `/cover/${id}/${medium ?? 'face'}`, + }), +})) + +const slot = { source: 'extracted' as const, updatedAt: '2026-01-01T00:00:00.000Z', width: null, height: null } +const book = { id: 7, title: 'Dear Debbie', coverVersion: 'v1', covers: { ebook: slot, audio: slot } } as Pick< + BookDetail, + 'id' | 'title' | 'covers' | 'coverVersion' +> + +function mountStack(props: { face?: 'ebook' | 'audio'; editable?: boolean } = {}) { + return mount(BookCoverStack, { + props: { book, face: props.face ?? 'audio', editable: props.editable }, + global: { stubs: { BookCoverArtwork: true } }, + }) +} + +describe('BookCoverStack', () => { + it('puts the face in front and the other cover behind', () => { + const wrapper = mountStack({ face: 'audio' }) + + expect(wrapper.get('[data-test="cover-stack"]').attributes('data-front')).toBe('audio') + expect(wrapper.get('[data-medium="audio"]').classes()).toContain('cover-stack__card--front') + expect(wrapper.get('[data-medium="ebook"]').classes()).toContain('cover-stack__card--back') + }) + + it('labels the front card as the lightbox opener and the one behind as the swap', () => { + const wrapper = mountStack({ face: 'audio' }) + + expect(wrapper.get('[data-test="cover-stack-audio"]').attributes('aria-label')).toBe('View larger cover') + expect(wrapper.get('[data-test="cover-stack-ebook"]').attributes('aria-label')).toBe('Show the book cover') + }) + + it('brings the cover behind forward when it is chosen, without opening anything', async () => { + const wrapper = mountStack({ face: 'audio' }) + + await wrapper.get('[data-test="cover-stack-ebook"]').trigger('click') + + expect(wrapper.get('[data-test="cover-stack"]').attributes('data-front')).toBe('ebook') + expect(wrapper.get('[data-test="cover-stack-audio"]').attributes('aria-label')).toBe('Show the audiobook cover') + expect(wrapper.get('[data-test="cover-stack-status"]').text()).toBe('Showing the book cover') + expect(wrapper.emitted('open')).toBeUndefined() + }) + + it('opens the cover in front and keeps it in front', async () => { + const wrapper = mountStack({ face: 'ebook' }) + + await wrapper.get('[data-test="cover-stack-ebook"]').trigger('click') + + expect(wrapper.emitted('open')).toEqual([['ebook']]) + expect(wrapper.get('[data-test="cover-stack"]').attributes('data-front')).toBe('ebook') + }) + + it('goes back to the face when the face changes', async () => { + const wrapper = mountStack({ face: 'audio' }) + await wrapper.get('[data-test="cover-stack-ebook"]').trigger('click') + + await wrapper.setProps({ face: 'ebook' }) + expect(wrapper.get('[data-test="cover-stack"]').attributes('data-front')).toBe('ebook') + + await wrapper.setProps({ face: 'audio' }) + expect(wrapper.get('[data-test="cover-stack"]').attributes('data-front')).toBe('audio') + }) + + it('asks each cover for its own slot', () => { + const wrapper = mountStack() + const sources = wrapper.findAllComponents({ name: 'BookCoverArtwork' }).map((artwork) => artwork.props('src')) + + expect(sources).toEqual(['/cover/7/ebook', '/cover/7/audio']) + }) + + it('offers cover editing only on the front card, and only to someone who can edit', async () => { + expect(mountStack({ editable: false }).find('[data-test="cover-stack-edit"]').exists()).toBe(false) + + const wrapper = mountStack({ editable: true }) + expect(wrapper.findAll('[data-test="cover-stack-edit"]')).toHaveLength(1) + await wrapper.get('[data-test="cover-stack-edit"]').trigger('click') + expect(wrapper.emitted('edit')).toHaveLength(1) + expect(wrapper.emitted('open')).toBeUndefined() + }) +}) diff --git a/client/src/features/book/components/detail/files/FilesHeroBar.vue b/client/src/features/book/components/detail/files/FilesHeroBar.vue index f15cb1b06..2aa93cae8 100644 --- a/client/src/features/book/components/detail/files/FilesHeroBar.vue +++ b/client/src/features/book/components/detail/files/FilesHeroBar.vue @@ -1,7 +1,7 @@ diff --git a/client/src/features/read-aloud/components/ReadAloudSparkles.vue b/client/src/features/read-aloud/components/ReadAloudSparkles.vue new file mode 100644 index 000000000..02a6fb1a8 --- /dev/null +++ b/client/src/features/read-aloud/components/ReadAloudSparkles.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/client/src/features/read-aloud/components/ReadAloudTrackList.vue b/client/src/features/read-aloud/components/ReadAloudTrackList.vue new file mode 100644 index 000000000..a38213a70 --- /dev/null +++ b/client/src/features/read-aloud/components/ReadAloudTrackList.vue @@ -0,0 +1,127 @@ + + + diff --git a/client/src/features/read-aloud/composables/useReadAloud.test.ts b/client/src/features/read-aloud/composables/useReadAloud.test.ts new file mode 100644 index 000000000..34a4faf10 --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloud.test.ts @@ -0,0 +1,426 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, ref, type EffectScope, type Ref } from 'vue' +import { flushPromises } from '@vue/test-utils' +import { READ_ALOUD_JOB_UPDATED_EVENT, READ_ALOUD_SOCKET_NAMESPACE, type ReadAloudJob } from '@bookorbit/types' +import { BOOK_ID, JOB_ID, errorResponse, jsonResponse, makeCapability, makeJob } from '../__tests__/fixtures' + +const mocks = vi.hoisted(() => { + type Handler = (...args: unknown[]) => void + const handlers = new Map>() + const socket = { + on: (event: string, handler: Handler) => { + if (!handlers.has(event)) handlers.set(event, new Set()) + handlers.get(event)!.add(handler) + return socket + }, + off: (event: string, handler: Handler) => { + handlers.get(event)?.delete(handler) + return socket + }, + disconnect: () => { + handlers.clear() + return socket + }, + emit(event: string, ...args: unknown[]) { + for (const handler of handlers.get(event) ?? []) handler(...args) + }, + reset() { + handlers.clear() + }, + } + return { + api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(), + createAuthenticatedSocket: vi.fn<(namespace: string, options?: object) => typeof socket>(() => socket), + socket, + } +}) + +vi.mock('@/lib/api', () => ({ api: mocks.api })) +vi.mock('@/lib/socket', () => ({ createAuthenticatedSocket: mocks.createAuthenticatedSocket })) + +import { READ_ALOUD_POLL_INTERVAL_MS, useReadAloud } from './useReadAloud' + +const CAPABILITY_URL = `/api/v1/books/${BOOK_ID}/read-aloud` +const JOB_URL = `/api/v1/read-aloud-jobs/${JOB_ID}` + +let scope: EffectScope | null = null +let visibility: DocumentVisibilityState = 'visible' + +type Route = (init?: RequestInit) => Response | Promise + +/** Answers by method and path; an unexpected request fails the test instead of hanging it. */ +function routeApi(routes: Record): void { + mocks.api.mockImplementation(async (input, init) => { + const method = init?.method ?? 'GET' + const key = `${method} ${String(input)}` + const route = routes[key] + if (!route) throw new Error(`Unexpected request: ${key}`) + return route(init) + }) +} + +function callsTo(method: string, url: string): number { + return mocks.api.mock.calls.filter(([input, init]) => String(input) === url && (init?.method ?? 'GET') === method).length +} + +function setVisibility(state: DocumentVisibilityState): void { + visibility = state + document.dispatchEvent(new Event('visibilitychange')) +} + +/** The fixtures' jobs are requested by user 1, so by default the viewer is their requester. */ +const REQUESTER_ID = 1 + +function setup( + overrides: { + enabled?: Ref + fileIds?: Ref + bookId?: Ref + viewerId?: Ref + onJobCompleted?: (job: ReadAloudJob) => void + } = {}, +) { + scope = effectScope() + const options = { + bookId: overrides.bookId ?? ref(BOOK_ID), + enabled: overrides.enabled ?? ref(true), + fileIds: overrides.fileIds ?? ref([101, 102, 103, 104]), + viewerId: overrides.viewerId ?? ref(REQUESTER_ID), + onJobCompleted: overrides.onJobCompleted, + } + return scope.run(() => useReadAloud(options))! +} + +beforeEach(() => { + vi.useFakeTimers() + mocks.api.mockReset() + mocks.createAuthenticatedSocket.mockClear() + mocks.socket.reset() + visibility = 'visible' + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility }) +}) + +afterEach(() => { + scope?.stop() + scope = null + vi.useRealTimers() +}) + +describe('useReadAloud', () => { + it('does not probe the API until the permission-backed flag is true', async () => { + const enabled = ref(false) + const result = setup({ enabled }) + await flushPromises() + expect(mocks.api).not.toHaveBeenCalled() + + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability()) }) + enabled.value = true + await flushPromises() + + expect(callsTo('GET', CAPABILITY_URL)).toBe(1) + expect(result.capability.value?.eligible).toBe(true) + expect(result.loading.value).toBe(false) + }) + + it('applies job updates pushed over the socket for this book only', async () => { + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ status: 'queued' }) })) }) + const result = setup() + await flushPromises() + + expect(mocks.createAuthenticatedSocket).toHaveBeenCalledWith(READ_ALOUD_SOCKET_NAMESPACE, expect.any(Object)) + + mocks.socket.emit(READ_ALOUD_JOB_UPDATED_EVENT, makeJob({ status: 'processing', progress: 55, updatedAt: '2026-01-01T00:10:00.000Z' })) + expect(result.job.value).toMatchObject({ status: 'processing', progress: 55 }) + + mocks.socket.emit(READ_ALOUD_JOB_UPDATED_EVENT, makeJob({ id: 'other', bookId: 99, status: 'failed', updatedAt: '2026-01-01T00:20:00.000Z' })) + expect(result.job.value?.id).toBe(JOB_ID) + + // An older copy arriving late never rolls the job back. + mocks.socket.emit(READ_ALOUD_JOB_UPDATED_EVENT, makeJob({ status: 'queued', updatedAt: '2026-01-01T00:05:00.000Z' })) + expect(result.job.value?.status).toBe('processing') + }) + + describe('live updates for someone else’s job', () => { + it('polls instead of holding a socket the server never pushes to', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ requestedBy: { id: 2, name: 'Sam' } }) })), + [`GET ${JOB_URL}`]: () => jsonResponse(makeJob({ requestedBy: { id: 2, name: 'Sam' }, progress: 60, updatedAt: '2026-01-01T00:10:00.000Z' })), + }) + const result = setup({ viewerId: ref(REQUESTER_ID) }) + await flushPromises() + + expect(mocks.createAuthenticatedSocket).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS) + expect(callsTo('GET', JOB_URL)).toBe(1) + expect(result.job.value?.progress).toBe(60) + }) + + it('gives a job whose requester was deleted no socket either', async () => { + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ requestedBy: { id: null, name: null } }) })) }) + setup({ viewerId: ref(null) }) + await flushPromises() + + expect(mocks.createAuthenticatedSocket).not.toHaveBeenCalled() + }) + + it('opens the socket once the signed-in account turns out to be the requester, and closes it when it is not', async () => { + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })) }) + const viewerId = ref(null) + const disconnect = vi.spyOn(mocks.socket, 'disconnect') + setup({ viewerId }) + await flushPromises() + expect(mocks.createAuthenticatedSocket).not.toHaveBeenCalled() + + viewerId.value = REQUESTER_ID + await flushPromises() + expect(mocks.createAuthenticatedSocket).toHaveBeenCalledTimes(1) + + viewerId.value = 2 + await flushPromises() + expect(disconnect).toHaveBeenCalledTimes(1) + disconnect.mockRestore() + }) + }) + + it('polls an active job every 30 seconds only while the page is visible', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })), + [`GET ${JOB_URL}`]: () => jsonResponse(makeJob()), + }) + setup() + await flushPromises() + + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS - 1_000) + expect(callsTo('GET', JOB_URL)).toBe(0) + await vi.advanceTimersByTimeAsync(1_000) + expect(callsTo('GET', JOB_URL)).toBe(1) + + setVisibility('hidden') + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS * 4) + expect(callsTo('GET', JOB_URL)).toBe(1) + + // Coming back catches up at once, then resumes the slow cadence. + setVisibility('visible') + await flushPromises() + expect(callsTo('GET', JOB_URL)).toBe(2) + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS) + expect(callsTo('GET', JOB_URL)).toBe(3) + }) + + it('never polls a finished job', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ status: 'failed', errorCode: 'alignment_failed' }) })), + }) + setup() + await flushPromises() + + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS * 3) + expect(callsTo('GET', JOB_URL)).toBe(0) + expect(mocks.createAuthenticatedSocket).not.toHaveBeenCalled() + }) + + it('stops polling a job whose status route answers 404', async () => { + let capabilityCalls = 0 + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => { + capabilityCalls += 1 + return jsonResponse(makeCapability({ latestJob: capabilityCalls === 1 ? makeJob() : null })) + }, + [`GET ${JOB_URL}`]: () => errorResponse(404), + }) + const result = setup() + await flushPromises() + + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS) + expect(callsTo('GET', JOB_URL)).toBe(1) + await flushPromises() + expect(result.job.value).toBeNull() + + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS * 3) + expect(callsTo('GET', JOB_URL)).toBe(1) + }) + + it('stops polling on 403 without dropping the job', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })), + [`GET ${JOB_URL}`]: () => errorResponse(403), + }) + const result = setup() + await flushPromises() + + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS * 4) + expect(callsTo('GET', JOB_URL)).toBe(1) + expect(result.job.value?.id).toBe(JOB_ID) + }) + + it('refetches the job after a failed publish so the card is not stuck in review', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ status: 'awaiting_review' }) })), + [`POST ${JOB_URL}/publish`]: () => errorResponse(500), + [`GET ${JOB_URL}`]: () => jsonResponse(makeJob({ status: 'failed', errorCode: 'publication_failed', updatedAt: '2026-01-01T02:00:00.000Z' })), + }) + const result = setup() + await flushPromises() + + await expect(result.publish()).resolves.toBe(false) + + expect(callsTo('GET', JOB_URL)).toBe(1) + expect(result.job.value).toMatchObject({ status: 'failed', errorCode: 'publication_failed' }) + expect(result.jobErrorKey.value).toBe('book.detail.files.readAloud.errors.publish') + expect(result.action.value).toBeNull() + }) + + it('refetches the job after a failed cancel and explains a conflict', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })), + [`DELETE ${JOB_URL}`]: () => errorResponse(409), + [`GET ${JOB_URL}`]: () => jsonResponse(makeJob({ status: 'completed', updatedAt: '2026-01-01T02:00:00.000Z' })), + }) + const result = setup() + await flushPromises() + + await expect(result.cancel()).resolves.toBe(false) + + expect(callsTo('GET', JOB_URL)).toBe(1) + expect(result.job.value?.status).toBe('completed') + expect(result.jobErrorKey.value).toBe('book.detail.files.readAloud.errors.conflict') + }) + + it('keeps a running job processing with cancelRequested after cancel, as the server answers', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })), + [`DELETE ${JOB_URL}`]: () => jsonResponse(makeJob({ cancelRequested: true, updatedAt: '2026-01-01T00:30:00.000Z' })), + }) + const result = setup() + await flushPromises() + + await expect(result.cancel()).resolves.toBe(true) + + expect(result.job.value).toMatchObject({ status: 'processing', cancelRequested: true }) + expect(result.jobErrorKey.value).toBeNull() + }) + + it('rechecks availability when the book gains or loses a file', async () => { + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ eligible: false, reason: 'no_epub', epubFiles: [] })) }) + const fileIds = ref([102, 103, 104]) + const result = setup({ fileIds }) + await flushPromises() + expect(callsTo('GET', CAPABILITY_URL)).toBe(1) + + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability()) }) + fileIds.value = [101, 102, 103, 104] + await flushPromises() + + expect(callsTo('GET', CAPABILITY_URL)).toBe(2) + expect(mocks.api).toHaveBeenLastCalledWith(CAPABILITY_URL, expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(result.capability.value?.eligible).toBe(true) + // A silent recheck never puts the entry back into its first-load state. + expect(result.loading.value).toBe(false) + }) + + it('does not report a job that loaded finished as a completion', async () => { + const onJobCompleted = vi.fn<(job: ReadAloudJob) => void>() + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ status: 'completed' }) })) }) + setup({ onJobCompleted }) + await flushPromises() + + expect(onJobCompleted).not.toHaveBeenCalled() + }) + + it('reports a completion exactly once when the watched job finishes', async () => { + const onJobCompleted = vi.fn<(job: ReadAloudJob) => void>() + routeApi({ [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob({ stage: 'publishing' }) })) }) + const result = setup({ onJobCompleted }) + await flushPromises() + + mocks.socket.emit(READ_ALOUD_JOB_UPDATED_EVENT, makeJob({ status: 'completed', updatedAt: '2026-01-01T01:00:00.000Z' })) + await flushPromises() + mocks.socket.emit(READ_ALOUD_JOB_UPDATED_EVENT, makeJob({ status: 'completed', updatedAt: '2026-01-01T01:00:01.000Z' })) + await flushPromises() + + expect(onJobCompleted).toHaveBeenCalledTimes(1) + expect(result.job.value?.status).toBe('completed') + // The freed slot and the new file change availability, so it is read again. + expect(callsTo('GET', CAPABILITY_URL)).toBeGreaterThanOrEqual(2) + }) + + it('maps create failures to dialog errors and refreshes a stale capability', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability()), + [`POST /api/v1/books/${BOOK_ID}/read-aloud-jobs`]: () => errorResponse(409), + }) + const result = setup() + await flushPromises() + + const outcome = await result.create({ epubFileId: 101, audioFileIds: [103, 102], language: 'en', granularity: 'sentence' }) + await flushPromises() + + expect(outcome).toEqual({ ok: false, errorKey: 'book.detail.files.readAloud.errors.createConflict' }) + expect(result.jobErrorKey.value).toBeNull() + expect(callsTo('GET', CAPABILITY_URL)).toBe(2) + }) + + it('adopts the job a successful create returns', async () => { + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability()), + [`POST /api/v1/books/${BOOK_ID}/read-aloud-jobs`]: () => jsonResponse(makeJob({ status: 'queued' }), 201), + }) + const result = setup() + await flushPromises() + + const input = { epubFileId: 101, audioFileIds: [103, 102], language: 'en', granularity: 'sentence' as const, replaceFileId: 300 } + await expect(result.create(input)).resolves.toEqual({ ok: true }) + + const [, init] = mocks.api.mock.calls.find(([url]) => String(url).endsWith('/read-aloud-jobs'))! + expect(JSON.parse(String(init?.body))).toEqual(input) + expect(result.job.value?.status).toBe('queued') + }) + + it('resets everything when the book changes', async () => { + const bookId = ref(BOOK_ID) + routeApi({ + [`GET ${CAPABILITY_URL}`]: () => jsonResponse(makeCapability({ latestJob: makeJob() })), + ['GET /api/v1/books/13/read-aloud']: () => jsonResponse(makeCapability()), + }) + const result = setup({ bookId }) + await flushPromises() + expect(result.job.value?.id).toBe(JOB_ID) + + bookId.value = 13 + await flushPromises() + + expect(result.job.value).toBeNull() + expect(callsTo('GET', '/api/v1/books/13/read-aloud')).toBe(1) + await vi.advanceTimersByTimeAsync(READ_ALOUD_POLL_INTERVAL_MS * 2) + expect(callsTo('GET', JOB_URL)).toBe(0) + }) + + it('reports a failed first check instead of hiding it', async () => { + routeApi({ [`GET ${CAPABILITY_URL}`]: () => errorResponse(500) }) + const result = setup() + await flushPromises() + + expect(result.loadFailed.value).toBe(true) + expect(result.capability.value).toBeNull() + }) + + it('counts checks that keep failing, shows a recheck as loading, and clears the count on an answer', async () => { + let answer: () => Response = () => errorResponse(500) + routeApi({ [`GET ${CAPABILITY_URL}`]: () => answer() }) + const result = setup() + await flushPromises() + expect(result.failedChecks.value).toBe(1) + + const recheck = result.reload() + expect(result.loading.value).toBe(true) + await recheck + expect(result.loading.value).toBe(false) + expect(result.loadFailed.value).toBe(true) + expect(result.failedChecks.value).toBe(2) + + answer = () => jsonResponse(makeCapability()) + await result.reload() + expect(result.loadFailed.value).toBe(false) + expect(result.failedChecks.value).toBe(0) + }) +}) diff --git a/client/src/features/read-aloud/composables/useReadAloud.ts b/client/src/features/read-aloud/composables/useReadAloud.ts new file mode 100644 index 000000000..b2640d9b1 --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloud.ts @@ -0,0 +1,407 @@ +import { computed, onScopeDispose, ref, shallowRef, watch, type Ref } from 'vue' +import type { Socket } from 'socket.io-client' +import { + READ_ALOUD_JOB_UPDATED_EVENT, + READ_ALOUD_SOCKET_NAMESPACE, + type CreateReadAloudJobRequest, + type ReadAloudCapability, + type ReadAloudDecisions, + type ReadAloudJob, +} from '@bookorbit/types' +import { api } from '@/lib/api' +import { createAuthenticatedSocket } from '@/lib/socket' +import { isActiveJob, isCompletedJob } from '../lib/read-aloud' + +/** + * Jobs run for hours, so the poll only has to notice a job someone else is running, or a socket + * that went quiet. The requester gets every change pushed and keeps this as a fallback. + */ +export const READ_ALOUD_POLL_INTERVAL_MS = 30_000 + +export type ReadAloudAction = 'create' | 'cancel' | 'publish' | 'retry' | 'resync' | 'dismiss' + +export type ReadAloudCreateResult = { ok: true } | { ok: false; errorKey: string } + +export type UseReadAloudOptions = { + bookId: Ref + /** Permission-backed: nothing is fetched or connected while this is false. */ + enabled: Ref + /** The book's current file ids; a change means availability may have changed too. */ + fileIds: Ref + /** The signed-in user. The server pushes a job's updates only to its requester, so only they get a socket. */ + viewerId: Ref + /** Called once when a job finishes publishing while the page watches it, never for a job that loaded finished. */ + onJobCompleted?: (job: ReadAloudJob) => void +} + +const ERRORS = 'book.detail.files.readAloud.errors' + +class HttpStatusError extends Error { + constructor(readonly status: number) { + super(`HTTP ${status}`) + } +} + +function statusOf(error: unknown): number | null { + return error instanceof HttpStatusError ? error.status : null +} + +function mutationErrorKey(action: Exclude, error: unknown): string { + const status = statusOf(error) + if (status === 403) return `${ERRORS}.forbidden` + if (status === 404) return `${ERRORS}.gone` + if (status === 409) return `${ERRORS}.conflict` + return `${ERRORS}.${action}` +} + +function createErrorKey(error: unknown): string { + const status = statusOf(error) + if (status === 400) return `${ERRORS}.createInvalid` + if (status === 403) return `${ERRORS}.forbidden` + if (status === 409) return `${ERRORS}.createConflict` + if (status === 503) return `${ERRORS}.createUnavailable` + return `${ERRORS}.create` +} + +function timeOf(value: string): number { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? 0 : parsed +} + +function documentHidden(): boolean { + return typeof document !== 'undefined' && document.visibilityState === 'hidden' +} + +export function useReadAloud(options: UseReadAloudOptions) { + const { bookId, enabled, fileIds, viewerId } = options + + const capability = shallowRef(null) + const job = shallowRef(null) + const checking = ref(false) + const loadFailed = ref(false) + /** Availability checks that failed in a row with nothing to show, so a repeat failure can say so. */ + const failedChecks = ref(0) + const action = ref(null) + /** The last failed job action, shown on the card only; the dialog keeps its own. */ + const jobErrorKey = ref(null) + + /** True only before this book's first answer, so a silent recheck never flashes a skeleton. */ + const loading = computed(() => checking.value && capability.value === null) + + let generation = 0 + let disposed = false + let capabilityController: AbortController | null = null + let pollTimer: ReturnType | null = null + /** A job whose status route answered 403 or 404; polling it again cannot succeed. */ + let pollStoppedForJobId: string | null = null + let socket: Socket | null = null + let socketConnectedBefore = false + /** + * Bumped by every live job update. A capability answer that started before one carries an older + * view of the job, so it may refresh the capability but must not roll the job back. + */ + let jobUpdateSeq = 0 + /** Jobs already known to be finished, so the completion callback fires once per job. */ + const completedJobIds = new Set() + + function canRun(): boolean { + return !disposed && enabled.value && Number.isSafeInteger(bookId.value) && bookId.value > 0 + } + + function clearPoll(): void { + if (pollTimer !== null) clearTimeout(pollTimer) + pollTimer = null + } + + function closeSocket(): void { + if (!socket) return + socket.off(READ_ALOUD_JOB_UPDATED_EVENT, handleSocketJob) + socket.off('connect', handleSocketConnect) + socket.disconnect() + socket = null + } + + function openSocket(): void { + socketConnectedBefore = false + const connection = createAuthenticatedSocket(READ_ALOUD_SOCKET_NAMESPACE, { + reconnection: true, + reconnectionDelay: 1_000, + reconnectionDelayMax: 10_000, + }) + connection.on(READ_ALOUD_JOB_UPDATED_EVENT, handleSocketJob) + connection.on('connect', handleSocketConnect) + socket = connection + } + + /** Live updates run only while a job can still change: a socket for its requester, a slow poll for everyone. */ + function syncLiveUpdates(): void { + const current = job.value + const live = canRun() && isActiveJob(current) + const pushed = live && current.requestedBy.id !== null && current.requestedBy.id === viewerId.value + if (pushed && !socket) openSocket() + else if (!pushed) closeSocket() + + clearPoll() + if (!live || !current || pollStoppedForJobId === current.id || documentHidden()) return + pollTimer = setTimeout(handlePollTick, READ_ALOUD_POLL_INTERVAL_MS) + } + + async function handlePollTick(): Promise { + pollTimer = null + await refreshJob() + if (pollTimer === null) syncLiveUpdates() + } + + function applyJob(next: ReadAloudJob | null, source: 'snapshot' | 'update'): void { + if (next && next.bookId !== bookId.value) return + const previous = job.value + if (next && previous) { + if (next.id === previous.id) { + // Socket pushes and poll answers can cross; an older copy never overwrites a newer one. + if (timeOf(next.updatedAt) < timeOf(previous.updatedAt)) return + } else if (source === 'update' && isActiveJob(previous) && timeOf(next.createdAt) < timeOf(previous.createdAt)) { + return + } + } + + if (source === 'update') jobUpdateSeq += 1 + if (next?.id !== previous?.id) jobErrorKey.value = null + job.value = next + + if (isCompletedJob(next) && !completedJobIds.has(next.id)) { + completedJobIds.add(next.id) + const watchedItFinish = previous?.id === next.id ? !isCompletedJob(previous) : source === 'update' + if (watchedItFinish) options.onJobCompleted?.(next) + } + + // A job that just stopped frees the book's slot and may have added a file, so availability moved. + if (source === 'update' && isActiveJob(previous) && !isActiveJob(next)) void loadCapability() + + syncLiveUpdates() + } + + async function loadCapability(): Promise { + if (!canRun()) return + const current = generation + capabilityController?.abort() + const controller = new AbortController() + capabilityController = controller + checking.value = true + const seqAtStart = jobUpdateSeq + try { + const response = await api(`/api/v1/books/${bookId.value}/read-aloud`, { signal: controller.signal }) + if (current !== generation || controller.signal.aborted) return + if (!response.ok) throw new HttpStatusError(response.status) + const result = (await response.json()) as ReadAloudCapability + if (current !== generation || controller.signal.aborted) return + capability.value = result + loadFailed.value = false + failedChecks.value = 0 + if (seqAtStart === jobUpdateSeq) applyJob(result.latestJob, 'snapshot') + } catch { + if (current !== generation || controller.signal.aborted) return + // A failed silent recheck keeps the last good answer; only a page with nothing to show reports it. + if (capability.value === null) { + loadFailed.value = true + failedChecks.value += 1 + } + } finally { + if (capabilityController === controller) { + capabilityController = null + checking.value = false + } + } + } + + async function refreshJob(): Promise { + const current = job.value + if (!current || !canRun()) return + const gen = generation + try { + const response = await api(`/api/v1/read-aloud-jobs/${encodeURIComponent(current.id)}`) + if (gen !== generation) return + if (response.status === 403 || response.status === 404) { + pollStoppedForJobId = current.id + clearPoll() + if (response.status === 404) void loadCapability() + return + } + if (!response.ok) return + applyJob((await response.json()) as ReadAloudJob, 'update') + } catch { + // The next poll or socket event catches up; a transient failure is not worth a message. + } + } + + function handleSocketJob(payload: ReadAloudJob): void { + if (!payload || typeof payload !== 'object' || payload.bookId !== bookId.value) return + applyJob(payload, 'update') + } + + /** The server replays nothing, so a reconnect catches up with one read instead of waiting for the poll. */ + function handleSocketConnect(): void { + if (socketConnectedBefore) void refreshJob() + socketConnectedBefore = true + } + + function handleVisibilityChange(): void { + if (documentHidden()) { + clearPoll() + return + } + const current = job.value + if (!canRun() || !isActiveJob(current) || pollStoppedForJobId === current.id) return + void refreshJob().then(() => { + if (pollTimer === null) syncLiveUpdates() + }) + } + + async function mutate(name: Exclude, path: string, init: RequestInit): Promise { + const current = job.value + if (!current || action.value !== null || !canRun()) return false + const gen = generation + action.value = name + jobErrorKey.value = null + try { + const response = await api(`/api/v1/read-aloud-jobs/${encodeURIComponent(current.id)}${path}`, init) + if (gen !== generation) return false + if (!response.ok) throw new HttpStatusError(response.status) + applyJob((await response.json()) as ReadAloudJob, 'update') + return true + } catch (error) { + if (gen !== generation) return false + jobErrorKey.value = mutationErrorKey(name, error) + // The job may have moved on server-side (failed, published, cancelled); show where it is now. + await refreshJob() + return false + } finally { + if (gen === generation) action.value = null + } + } + + async function create(input: CreateReadAloudJobRequest): Promise { + if (action.value !== null || !canRun()) return { ok: false, errorKey: `${ERRORS}.create` } + const gen = generation + action.value = 'create' + try { + const response = await api(`/api/v1/books/${bookId.value}/read-aloud-jobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (gen !== generation) return { ok: false, errorKey: `${ERRORS}.create` } + if (!response.ok) throw new HttpStatusError(response.status) + applyJob((await response.json()) as ReadAloudJob, 'update') + return { ok: true } + } catch (error) { + // A rejected selection, a job someone else started or an offline worker all mean the + // capability this dialog was built from is stale. + if (gen === generation && statusOf(error) !== 403) void loadCapability() + return { ok: false, errorKey: createErrorKey(error) } + } finally { + if (gen === generation) action.value = null + } + } + + function cancel(): Promise { + return mutate('cancel', '', { method: 'DELETE' }) + } + + function publish(): Promise { + return mutate('publish', '/publish', { method: 'POST' }) + } + + function retry(): Promise { + return mutate('retry', '/retry', { method: 'POST' }) + } + + function dismiss(): Promise { + return mutate('dismiss', '/dismiss', { method: 'POST' }) + } + + function resync(decisions: ReadAloudDecisions): Promise { + return mutate('resync', '/resync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ decisions }), + }) + } + + function reload(): Promise { + return loadCapability() + } + + function clearJobError(): void { + jobErrorKey.value = null + } + + function reset(): void { + generation += 1 + capabilityController?.abort() + capabilityController = null + checking.value = false + clearPoll() + closeSocket() + capability.value = null + job.value = null + loadFailed.value = false + failedChecks.value = 0 + action.value = null + jobErrorKey.value = null + pollStoppedForJobId = null + completedJobIds.clear() + } + + watch( + [bookId, enabled], + () => { + reset() + void loadCapability() + }, + { immediate: true }, + ) + + // Uploading the missing EPUB, deleting a track or a read-along: availability and the dialog's + // file lists follow the book rather than the moment the tab opened. + watch( + () => [bookId.value, fileIds.value.join(',')] as const, + (next, previous) => { + if (!previous || next[0] !== previous[0] || next[1] === previous[1]) return + void loadCapability() + }, + ) + + // The account can load after the tab does; the socket follows whoever is signed in. + watch(viewerId, () => syncLiveUpdates()) + + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', handleVisibilityChange) + + onScopeDispose(() => { + disposed = true + generation += 1 + capabilityController?.abort() + capabilityController = null + clearPoll() + closeSocket() + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', handleVisibilityChange) + }) + + return { + capability, + job, + loading, + loadFailed, + failedChecks, + action, + jobErrorKey, + reload, + refreshJob, + create, + cancel, + publish, + retry, + resync, + dismiss, + clearJobError, + } +} diff --git a/client/src/features/read-aloud/composables/useReadAloudEvents.ts b/client/src/features/read-aloud/composables/useReadAloudEvents.ts new file mode 100644 index 000000000..c7773271f --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloudEvents.ts @@ -0,0 +1,114 @@ +import { computed, onScopeDispose, ref, shallowRef, watch, type Ref } from 'vue' +import type { ReadAloudJobEvent, ReadAloudJobEventsResponse } from '@bookorbit/types' +import { api } from '@/lib/api' + +/** A socket can push a job update every second; the log does not need to follow that closely. */ +export const READ_ALOUD_EVENTS_MIN_INTERVAL_MS = 5_000 + +/** + * The activity log of one job, read only while it is open. The first read takes everything, later + * ones ask only for events newer than the last one held, whenever the job itself changes. + */ +export function useReadAloudEvents(jobId: Ref, open: Ref, version: Ref) { + const events = shallowRef([]) + const loaded = ref(false) + const failed = ref(false) + const fetching = ref(false) + const loading = computed(() => fetching.value && !loaded.value) + + let generation = 0 + let lastId = 0 + let lastFetchAt = 0 + let queued = false + let throttleTimer: ReturnType | null = null + + function clearThrottle(): void { + if (throttleTimer !== null) clearTimeout(throttleTimer) + throttleTimer = null + } + + async function fetchEvents(): Promise { + if (fetching.value) { + queued = true + return + } + const gen = generation + fetching.value = true + try { + const query = lastId > 0 ? `?after=${lastId}` : '' + const response = await api(`/api/v1/read-aloud-jobs/${encodeURIComponent(jobId.value)}/events${query}`) + if (gen !== generation) return + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const body = (await response.json()) as ReadAloudJobEventsResponse + if (gen !== generation) return + const fresh = body.events.filter((event) => event.id > lastId) + if (fresh.length > 0) { + events.value = [...events.value, ...fresh] + lastId = Math.max(...fresh.map((event) => event.id)) + } + loaded.value = true + failed.value = false + } catch { + if (gen === generation) failed.value = true + } finally { + if (gen === generation) { + fetching.value = false + lastFetchAt = Date.now() + if (queued) { + queued = false + schedule() + } + } + } + } + + function schedule(): void { + if (!open.value || throttleTimer !== null) return + const wait = lastFetchAt + READ_ALOUD_EVENTS_MIN_INTERVAL_MS - Date.now() + if (wait <= 0) { + void fetchEvents() + return + } + throttleTimer = setTimeout(() => { + throttleTimer = null + if (open.value) void fetchEvents() + }, wait) + } + + function reset(): void { + generation += 1 + clearThrottle() + events.value = [] + loaded.value = false + failed.value = false + fetching.value = false + queued = false + lastId = 0 + lastFetchAt = 0 + } + + watch(jobId, () => { + reset() + if (open.value) void fetchEvents() + }) + + watch( + open, + (isOpen) => { + if (isOpen) void fetchEvents() + else clearThrottle() + }, + { immediate: true }, + ) + + watch(version, () => { + if (open.value) schedule() + }) + + onScopeDispose(() => { + generation += 1 + clearThrottle() + }) + + return { events, loading, failed, reload: fetchEvents } +} diff --git a/client/src/features/read-aloud/composables/useReadAloudInvite.test.ts b/client/src/features/read-aloud/composables/useReadAloudInvite.test.ts new file mode 100644 index 000000000..76590bd2e --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloudInvite.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, ref, type EffectScope } from 'vue' +import { flushPromises } from '@vue/test-utils' +import { Permission, type BookDetail } from '@bookorbit/types' +import { BOOK_ID, errorResponse, jsonResponse, makeCapability } from '../__tests__/fixtures' + +const mocks = vi.hoisted(() => ({ + api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(), + hasPermission: vi.fn<(name: string) => boolean>(), + demo: { value: false }, +})) + +vi.mock('@/lib/api', () => ({ api: mocks.api })) +vi.mock('@/features/auth/composables/usePermissions', () => ({ + usePermissions: () => ({ hasPermission: mocks.hasPermission, isDemoRestrictedAccount: mocks.demo }), +})) + +import { useReadAloudInvite } from './useReadAloudInvite' + +const CAPABILITY_URL = `/api/v1/books/${BOOK_ID}/read-aloud` + +let scope: EffectScope | null = null + +function setup(files: number[] = [101, 102]) { + const book = ref({ id: BOOK_ID, files: files.map((id) => ({ id })) } as unknown as Pick) + scope = effectScope() + const result = scope.run(() => useReadAloudInvite(book))! + return { book, ...result } +} + +beforeEach(() => { + mocks.api.mockReset() + mocks.hasPermission.mockReset() + mocks.hasPermission.mockImplementation((name) => name === Permission.LibraryGenerateReadAloud) + mocks.demo.value = false +}) + +afterEach(() => { + scope?.stop() + scope = null +}) + +describe('useReadAloudInvite', () => { + it('invites when the book can be made into a read-aloud and has none yet', async () => { + mocks.api.mockResolvedValue(jsonResponse(makeCapability())) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(true) + expect(mocks.api).toHaveBeenCalledWith(CAPABILITY_URL, expect.objectContaining({ signal: expect.any(AbortSignal) })) + }) + + it('does not ask, or invite, without the permission', async () => { + mocks.hasPermission.mockReturnValue(false) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + expect(mocks.api).not.toHaveBeenCalled() + }) + + it('does not ask a demo-restricted account', async () => { + mocks.demo.value = true + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + expect(mocks.api).not.toHaveBeenCalled() + }) + + it.each([ + ['the worker is offline', makeCapability({ eligible: false, reason: 'worker_unavailable' })], + ['a job is already running', makeCapability({ eligible: false, reason: 'job_active' })], + ['the book has no audiobook', makeCapability({ eligible: false, reason: 'no_audiobook' })], + ['no worker is configured', makeCapability({ workerConfigured: false, eligible: false, reason: 'worker_not_configured' })], + ])('does not invite when %s', async (_name, capability) => { + mocks.api.mockResolvedValue(jsonResponse(capability)) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + }) + + it('does not invite a book that already has a read-aloud', async () => { + mocks.api.mockResolvedValue( + jsonResponse(makeCapability({ existingReadAlongs: [{ fileId: 9, filename: 'book.readaloud.epub', sizeBytes: 1, generatedByJobId: null }] })), + ) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + }) + + it('offers nothing when the lookup fails', async () => { + mocks.api.mockResolvedValue(errorResponse(500)) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + }) + + it('offers nothing when the lookup throws', async () => { + mocks.api.mockRejectedValue(new Error('offline')) + const { invite } = setup() + await flushPromises() + + expect(invite.value).toBe(false) + }) + + it('asks again when the book gains a file, so a finished read-aloud retires the invitation', async () => { + mocks.api.mockResolvedValueOnce(jsonResponse(makeCapability())) + const { invite, book } = setup() + await flushPromises() + expect(invite.value).toBe(true) + + mocks.api.mockResolvedValueOnce( + jsonResponse(makeCapability({ existingReadAlongs: [{ fileId: 9, filename: 'book.readaloud.epub', sizeBytes: 1, generatedByJobId: 'job' }] })), + ) + book.value = { id: BOOK_ID, files: [{ id: 101 }, { id: 102 }, { id: 103 }] } as unknown as Pick + await flushPromises() + + expect(mocks.api).toHaveBeenCalledTimes(2) + expect(invite.value).toBe(false) + }) + + it('keeps the invite for the same book while it asks again, instead of blinking it off', async () => { + mocks.api.mockResolvedValueOnce(jsonResponse(makeCapability())) + const { invite, book } = setup() + await flushPromises() + expect(invite.value).toBe(true) + + let answer!: (response: Response) => void + mocks.api.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + book.value = { id: BOOK_ID, files: [{ id: 101 }, { id: 102 }, { id: 103 }] } as unknown as Pick + await flushPromises() + + expect(mocks.api).toHaveBeenCalledTimes(2) + expect(invite.value).toBe(true) + + answer(jsonResponse(makeCapability())) + await flushPromises() + expect(invite.value).toBe(true) + }) + + it("drops the last book's invite the moment another book takes its place", async () => { + mocks.api.mockResolvedValueOnce(jsonResponse(makeCapability())) + const { invite, book } = setup() + await flushPromises() + expect(invite.value).toBe(true) + + let answer!: (response: Response) => void + mocks.api.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + book.value = { id: BOOK_ID + 1, files: [{ id: 201 }] } as unknown as Pick + + expect(invite.value).toBe(false) + await flushPromises() + expect(mocks.api).toHaveBeenLastCalledWith(`/api/v1/books/${BOOK_ID + 1}/read-aloud`, expect.any(Object)) + expect(invite.value).toBe(false) + + answer(jsonResponse(makeCapability())) + await flushPromises() + expect(invite.value).toBe(true) + }) +}) diff --git a/client/src/features/read-aloud/composables/useReadAloudInvite.ts b/client/src/features/read-aloud/composables/useReadAloudInvite.ts new file mode 100644 index 000000000..805783647 --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloudInvite.ts @@ -0,0 +1,55 @@ +import { computed, onScopeDispose, shallowRef, watch, type Ref } from 'vue' +import { Permission, type BookDetail, type ReadAloudCapability } from '@bookorbit/types' +import { api } from '@/lib/api' +import { usePermissions } from '@/features/auth/composables/usePermissions' +import { resolveReadAloudEntry } from '../lib/read-aloud' + +/** + * Whether a book can be offered a read-aloud right now and has none yet, for entry points outside + * the Files tab. One lookup, no live updates: it asks again when the book's files change. A failed + * or refused lookup simply offers nothing, since the Files tab explains the real state. + */ +export function useReadAloudInvite(book: Ref>) { + const { hasPermission, isDemoRestrictedAccount } = usePermissions() + const enabled = computed(() => hasPermission(Permission.LibraryGenerateReadAloud) && !isDemoRestrictedAccount.value) + /** The last answer, tagged with its book so another book's answer can never show here. */ + const answer = shallowRef<{ bookId: number; capability: ReadAloudCapability } | null>(null) + let controller: AbortController | null = null + + async function load() { + controller?.abort() + controller = null + const bookId = book.value.id + // The same book keeps its last answer while asking again, so the invite does not blink off and on. + if (!enabled.value || answer.value?.bookId !== bookId) answer.value = null + if (!enabled.value) return + const current = new AbortController() + controller = current + try { + const response = await api(`/api/v1/books/${bookId}/read-aloud`, { signal: current.signal }) + if (current.signal.aborted) return + if (!response.ok) { + answer.value = null + return + } + const capability = (await response.json()) as ReadAloudCapability + if (current.signal.aborted) return + answer.value = { bookId, capability } + } catch { + // Offering nothing is the safe answer to a lookup that did not finish. + if (!current.signal.aborted) answer.value = null + } + } + + watch([() => book.value.id, () => book.value.files.map((file) => file.id).join(','), enabled], load, { immediate: true }) + onScopeDispose(() => controller?.abort()) + + const invite = computed(() => { + const current = answer.value + if (!enabled.value || current === null || current.bookId !== book.value.id) return false + const entry = resolveReadAloudEntry({ enabled: true, capability: current.capability, loadFailed: false, job: null }) + return entry.kind === 'available' && current.capability.existingReadAlongs.length === 0 + }) + + return { invite } +} diff --git a/client/src/features/read-aloud/composables/useReadAloudReport.ts b/client/src/features/read-aloud/composables/useReadAloudReport.ts new file mode 100644 index 000000000..25ba1ff4c --- /dev/null +++ b/client/src/features/read-aloud/composables/useReadAloudReport.ts @@ -0,0 +1,51 @@ +import { computed, onScopeDispose, ref, shallowRef, watch, type Ref } from 'vue' +import type { ReadAloudJobReport } from '@bookorbit/types' +import { api } from '@/lib/api' + +/** The per-chapter report of a job awaiting review, fetched once per job. */ +export function useReadAloudReport(jobId: Ref) { + const report = shallowRef(null) + const failed = ref(false) + const fetching = ref(false) + const loading = computed(() => fetching.value && report.value === null) + let controller: AbortController | null = null + + async function load(): Promise { + controller?.abort() + const current = new AbortController() + controller = current + fetching.value = true + failed.value = false + try { + const response = await api(`/api/v1/read-aloud-jobs/${encodeURIComponent(jobId.value)}/report`, { signal: current.signal }) + if (current.signal.aborted) return + if (!response.ok) throw new Error(`HTTP ${response.status}`) + const body = (await response.json()) as ReadAloudJobReport + if (current.signal.aborted) return + report.value = body + } catch { + if (!current.signal.aborted) failed.value = true + } finally { + if (controller === current) { + controller = null + fetching.value = false + } + } + } + + watch( + jobId, + () => { + report.value = null + void load() + }, + { immediate: true }, + ) + + onScopeDispose(() => { + controller?.abort() + controller = null + }) + + return { report, loading, failed, reload: load } +} diff --git a/client/src/features/read-aloud/lib/read-aloud.ts b/client/src/features/read-aloud/lib/read-aloud.ts new file mode 100644 index 000000000..478817070 --- /dev/null +++ b/client/src/features/read-aloud/lib/read-aloud.ts @@ -0,0 +1,374 @@ +import { + isReadAloudErrorCode, + READ_ALOUD_ACTIVE_JOB_STATUSES, + type ReadAloudCapability, + type ReadAloudChapterStatus, + type ReadAloudEligibilityReason, + type ReadAloudErrorCode, + type ReadAloudJob, + type ReadAloudJobEvent, + type ReadAloudJobStage, + type ReadAloudJobStatus, + type ReadAloudStageUnit, +} from '@bookorbit/types' +import { formatNumber } from '@/i18n/formatters' + +const ACTIVE_STATUSES = new Set(READ_ALOUD_ACTIVE_JOB_STATUSES) + +/** Queued, processing or waiting for review: the job holds the book's slot and can still change. */ +export function isActiveJob(job: ReadAloudJob | null | undefined): job is ReadAloudJob { + return job != null && ACTIVE_STATUSES.has(job.status) +} + +export function isRunningJob(job: ReadAloudJob | null | undefined): job is ReadAloudJob { + return job != null && (job.status === 'queued' || job.status === 'processing') +} + +export function isCompletedJob(job: ReadAloudJob | null | undefined): job is ReadAloudJob { + return job != null && (job.status === 'completed' || job.status === 'completed_with_warnings') +} + +export type ReadAloudCoverageSummary = { + /** Sentences with narration timing, matched or estimated between matches. */ + matched: number + /** The text the narration covers; the whole EPUB for jobs validated before coverage was recorded. */ + total: number + /** Share of the audio placed against text, or null without a duration. */ + audioFraction: number | null + outsideTitles: string[] + /** Chapters outside the narration beyond the titles listed. */ + outsideMore: number +} + +/** The numbers a finished or reviewable job shows: how much matched, and what the narration never covers. */ +export function coverageSummary(job: Pick): ReadAloudCoverageSummary | null { + const report = job.report + if (!report || report.sentences <= 0) return null + const coverage = job.coverage + const total = coverage ? coverage.storySentences : report.sentences + return { + matched: Math.min(total, report.matchedSentences + report.interpolatedSentences), + total, + audioFraction: report.seconds > 0 ? Math.min(1, (report.matchedSeconds + report.interpolatedSeconds) / report.seconds) : null, + outsideTitles: coverage?.outsideTitles ?? [], + outsideMore: coverage ? Math.max(0, coverage.outsideChapterCount - coverage.outsideTitles.length) : 0, + } +} + +/** + * Reasons that only an admin or a library change can clear. A button that can never work on this + * book is noise on every visit, so the entry stays out of the toolbar entirely. + */ +const HIDDEN_REASONS = new Set(['worker_not_configured', 'single_file_library', 'no_audiobook']) + +const REASON_KEYS: Record, string> = { + worker_unavailable: 'book.detail.files.readAloud.reasons.worker_unavailable', + worker_incompatible: 'book.detail.files.readAloud.reasons.worker_incompatible', + book_missing: 'book.detail.files.readAloud.reasons.book_missing', + no_epub: 'book.detail.files.readAloud.reasons.no_epub', + too_many_audio_files: 'book.detail.files.readAloud.reasons.too_many_audio_files', + epub_not_allowed: 'book.detail.files.readAloud.reasons.epub_not_allowed', + library_not_mounted: 'book.detail.files.readAloud.reasons.library_not_mounted', + job_active: 'book.detail.files.readAloud.reasons.job_active', +} +const NO_EPUB_WITH_EXISTING_KEY = 'book.detail.files.readAloud.reasons.no_epub_existing' +const UNKNOWN_REASON_KEY = 'book.detail.files.readAloud.reasons.unknown' + +export type ReadAloudEntryState = + | { kind: 'hidden' } + | { kind: 'available' } + | { kind: 'unavailable'; reason: ReadAloudEligibilityReason | null; reasonKey: string } + | { kind: 'loadFailed' } + +export function resolveReadAloudEntry(input: { + enabled: boolean + capability: ReadAloudCapability | null + loadFailed: boolean + job: ReadAloudJob | null +}): ReadAloudEntryState { + const { enabled, capability, loadFailed, job } = input + if (!enabled) return { kind: 'hidden' } + if (!capability) return loadFailed ? { kind: 'loadFailed' } : { kind: 'hidden' } + if (!capability.workerConfigured) return { kind: 'hidden' } + const reason = capability.reason + if (reason !== null && HIDDEN_REASONS.has(reason)) return { kind: 'hidden' } + if (isActiveJob(job)) return { kind: 'unavailable', reason: 'job_active', reasonKey: REASON_KEYS.job_active } + if (reason === 'no_epub' && capability.existingReadAlongs.length > 0) { + return { kind: 'unavailable', reason, reasonKey: NO_EPUB_WITH_EXISTING_KEY } + } + if (reason !== null) { + const key = (REASON_KEYS as Partial>)[reason] ?? UNKNOWN_REASON_KEY + return { kind: 'unavailable', reason, reasonKey: key } + } + if (!capability.eligible) return { kind: 'unavailable', reason: null, reasonKey: UNKNOWN_REASON_KEY } + return { kind: 'available' } +} + +/** + * The line under the Files toolbar when the entry cannot start a job, or null. A check that fails + * again on retry says how often, so a repeat failure never reads as nothing having happened. + */ +export function readAloudEntryNote( + entry: ReadAloudEntryState, + context: { failedChecks: number; jobCardShown: boolean }, + t: (key: string, values?: Record) => string, +): string | null { + if (entry.kind === 'loadFailed') { + const retries = context.failedChecks - 1 + return retries > 0 ? t('book.detail.files.readAloud.loadFailedAgain', { count: retries }) : t('book.detail.files.readAloud.loadFailed') + } + if (entry.kind !== 'unavailable') return null + // The job card right below already says a job is running, and says more. + if (entry.reason === 'job_active' && context.jobCardShown) return null + return t(entry.reasonKey) +} + +const ERROR_KEYS: Record = { + worker_unavailable: 'book.detail.files.readAloud.errors.job.worker_unavailable', + worker_incompatible: 'book.detail.files.readAloud.errors.job.worker_incompatible', + worker_crashed: 'book.detail.files.readAloud.errors.job.worker_crashed', + library_not_mounted: 'book.detail.files.readAloud.errors.job.library_not_mounted', + source_missing: 'book.detail.files.readAloud.errors.job.source_missing', + source_changed: 'book.detail.files.readAloud.errors.job.source_changed', + invalid_source: 'book.detail.files.readAloud.errors.job.invalid_source', + unsupported_language: 'book.detail.files.readAloud.errors.job.unsupported_language', + insufficient_storage: 'book.detail.files.readAloud.errors.job.insufficient_storage', + audio_processing_failed: 'book.detail.files.readAloud.errors.job.audio_processing_failed', + model_download_failed: 'book.detail.files.readAloud.errors.job.model_download_failed', + inference_failed: 'book.detail.files.readAloud.errors.job.inference_failed', + markup_failed: 'book.detail.files.readAloud.errors.job.markup_failed', + alignment_failed: 'book.detail.files.readAloud.errors.job.alignment_failed', + invalid_output: 'book.detail.files.readAloud.errors.job.invalid_output', + publication_failed: 'book.detail.files.readAloud.errors.job.publication_failed', + review_expired: 'book.detail.files.readAloud.errors.job.review_expired', + discarded: 'book.detail.files.readAloud.errors.job.discarded', + cancelled: 'book.detail.files.readAloud.errors.job.cancelled', + unknown: 'book.detail.files.readAloud.errors.job.unknown', +} + +/** A code this build does not know reads as `unknown` rather than as a raw translation key. */ +export function jobErrorMessageKey(code: string | null, te: (key: string) => boolean): string { + const key = isReadAloudErrorCode(code) ? ERROR_KEYS[code] : ERROR_KEYS.unknown + return te(key) ? key : ERROR_KEYS.unknown +} + +const STAGE_KEYS: Record = { + queued: 'book.detail.files.readAloud.stages.queued', + preparing_audio: 'book.detail.files.readAloud.stages.preparing_audio', + downloading_model: 'book.detail.files.readAloud.stages.downloading_model', + generating_emissions: 'book.detail.files.readAloud.stages.generating_emissions', + marking_up_ebook: 'book.detail.files.readAloud.stages.marking_up_ebook', + aligning: 'book.detail.files.readAloud.stages.aligning', + validating: 'book.detail.files.readAloud.stages.validating', + awaiting_review: 'book.detail.files.readAloud.stages.awaiting_review', + publishing: 'book.detail.files.readAloud.stages.publishing', + completed: 'book.detail.files.readAloud.stages.completed', +} + +/** Unknown stages from a newer server fall back to the generic "working" label. */ +export function stageLabelKey(stage: string | null): string { + return (stage && (STAGE_KEYS as Record)[stage]) || 'book.detail.files.readAloud.stages.working' +} + +const STAGE_UNIT_KEYS: Record = { + file: 'book.detail.files.readAloud.stageDetail.file', + track: 'book.detail.files.readAloud.stageDetail.track', + chapter: 'book.detail.files.readAloud.stageDetail.chapter', +} + +export function stageDetailKey(unit: string): string { + return (STAGE_UNIT_KEYS as Record)[unit] ?? 'book.detail.files.readAloud.stageDetail.step' +} + +const DEVICE_KEYS: Record = { + cpu: 'book.detail.files.readAloud.devices.cpu', + mlx: 'book.detail.files.readAloud.devices.mlx', + cuda: 'book.detail.files.readAloud.devices.cuda', + webgpu: 'book.detail.files.readAloud.devices.webgpu', +} + +export function deviceLabel(device: string | null, t: (key: string, values?: Record) => string): string | null { + if (!device) return null + const key = DEVICE_KEYS[device] + return key ? t(key) : t('book.detail.files.readAloud.devices.other', { device }) +} + +/** The worker's own stages, grouped into the steps the card's stepper shows. */ +export const READ_ALOUD_STEPS = [ + { key: 'audio', stages: ['preparing_audio'] }, + { key: 'speech', stages: ['downloading_model', 'generating_emissions'] }, + { key: 'text', stages: ['marking_up_ebook'] }, + { key: 'align', stages: ['aligning'] }, + { key: 'check', stages: ['validating'] }, + { key: 'publish', stages: ['awaiting_review', 'publishing', 'completed'] }, +] as const satisfies readonly { key: string; stages: readonly ReadAloudJobStage[] }[] + +export type ReadAloudStepKey = (typeof READ_ALOUD_STEPS)[number]['key'] + +/** Index of the step a stage belongs to; -1 while queued or for a stage this build does not know. */ +export function stepIndexOf(stage: string): number { + return READ_ALOUD_STEPS.findIndex((step) => (step.stages as readonly string[]).includes(stage)) +} + +const CHAPTER_STATUS_KEYS: Record = { + aligned: 'book.detail.files.readAloud.review.status.aligned', + 'not-found': 'book.detail.files.readAloud.review.status.notFound', + 'too-short': 'book.detail.files.readAloud.review.status.tooShort', + excluded: 'book.detail.files.readAloud.review.status.excluded', + 'audio-only': 'book.detail.files.readAloud.review.status.audioOnly', +} + +/** A chapter before the narration starts or after it ends, which is expected to have no audio. */ +export const OUTSIDE_NARRATION_STATUS_KEY = 'book.detail.files.readAloud.review.status.outside' + +export function chapterStatusKey(status: string): string { + return (CHAPTER_STATUS_KEYS as Record)[status] ?? 'book.detail.files.readAloud.review.status.unknown' +} + +/** Event codes the server and worker emit. Copy comes from these, never from the English `message`. */ +const EVENTS = 'book.detail.files.readAloud.log.events' + +const EVENT_CODES = new Set([ + 'job_created', + 'claimed', + 'job_accepted', + 'disk_checked', + 'disk_check_failed', + 'stage_started', + 'stage_finished', + 'stage_skipped', + 'stage_failed', + 'model_download_started', + 'model_download_finished', + 'emissions_cached', + 'audio_reencoded_cbr', + 'stalign_warning', + 'waiting_for_worker', + 'worker_reachable_again', + 'worker_lost_job', + 'requeued', + 'recovered', + 'output_validated', + 'needs_review', + 'publish_requested', + 'published', + 'file_added', + 'file_replaced', + 'file_already_attached', + 'replace_target_missing', + 'replace_not_permitted', + 'job_completed', + 'cancel_requested', + 'runner_killed', + 'job_cancelled', + 'cancelled', + 'discarded', + 'job_failed', + 'retry_requested', + 'resync_requested', + 'review_reminder_sent', + 'review_expired', +]) + +export type ReadAloudEventText = { + /** Localized summary from the event code. */ + text: string + /** The raw diagnostic message, shown for problems and for codes this build does not know. */ + detail: string | null +} + +/** Seconds, minutes or hours as a locale-formatted unit, for how long one step took. */ +export function formatStepDuration(ms: number): string { + const seconds = Math.max(0, ms) / 1000 + if (seconds < 60) return formatNumber(seconds, { style: 'unit', unit: 'second', unitDisplay: 'short', maximumFractionDigits: seconds < 10 ? 1 : 0 }) + if (seconds < 3600) return formatNumber(seconds / 60, { style: 'unit', unit: 'minute', unitDisplay: 'short', maximumFractionDigits: 0 }) + return formatNumber(seconds / 3600, { style: 'unit', unit: 'hour', unitDisplay: 'short', maximumFractionDigits: 1 }) +} + +export function eventText( + event: ReadAloudJobEvent, + t: (key: string, values?: Record) => string, + te: (key: string) => boolean, +): ReadAloudEventText { + const detail = event.level === 'info' ? null : event.message + if (EVENT_CODES.has(event.code)) { + const durationMs = typeof event.data?.durationMs === 'number' ? event.data.durationMs : null + if (event.code === 'stage_finished' && durationMs === null) return { text: t(`${EVENTS}.stage_finished_plain`), detail } + return { text: t(`${EVENTS}.${event.code}`, durationMs === null ? {} : { duration: formatStepDuration(durationMs) }), detail } + } + if (isReadAloudErrorCode(event.code)) return { text: t(jobErrorMessageKey(event.code, te)), detail: event.message } + return { text: t('book.detail.files.readAloud.log.other'), detail: event.message ?? event.code } +} + +const NOTIFICATIONS = 'notifications.readAloud' + +export type ReadAloudNotificationCopy = { title: string; message: string | null } + +/** + * The reader's-locale copy for a read-aloud notification, from the book title and error code the + * server stores in `meta`. Null for other types, and for notifications stored before `meta` + * carried the title, which keep the server's English fallback. + */ +export function readAloudNotificationCopy( + notification: { type: string; meta: Record | null }, + t: (key: string, values?: Record) => string, + te: (key: string) => boolean, +): ReadAloudNotificationCopy | null { + const meta = notification.meta + if (!meta || !Object.hasOwn(meta, 'bookTitle')) return null + const stored = typeof meta.bookTitle === 'string' ? meta.bookTitle.trim() : '' + const title = stored || t(`${NOTIFICATIONS}.untitledBook`) + switch (notification.type) { + case 'read_aloud_ready': + return { title: t(`${NOTIFICATIONS}.readyTitle`, { title }), message: null } + case 'read_aloud_needs_review': + return { title: t(`${NOTIFICATIONS}.needsReviewTitle`, { title }), message: t(`${NOTIFICATIONS}.needsReviewMessage`) } + case 'read_aloud_failed': + return { + title: t(`${NOTIFICATIONS}.failedTitle`, { title }), + message: t(jobErrorMessageKey(typeof meta.errorCode === 'string' ? meta.errorCode : null, te)), + } + case 'read_aloud_review_expiring': + return { title: t(`${NOTIFICATIONS}.reviewExpiringTitle`, { title }), message: t(`${NOTIFICATIONS}.reviewExpiringMessage`) } + default: + return null + } +} + +/** Hours and minutes, never seconds: everything here runs for minutes to hours. */ +export function formatReadAloudDuration(seconds: number, t: (key: string, values?: Record) => string): string { + const safe = Math.max(0, seconds) + const hours = Math.floor(safe / 3600) + const minutes = Math.floor((safe % 3600) / 60) + if (hours > 0) return t('book.detail.files.duration.hoursMinutes', { hours, minutes }) + return t('book.detail.files.duration.minutes', { minutes: Math.max(minutes, 1) }) +} + +/** The primary language subtag of a BCP 47 tag, so "en-GB" picks the English model. */ +export function baseLanguage(value: string | null | undefined): string { + if (!value) return '' + try { + return new Intl.Locale(value).language + } catch { + return '' + } +} + +export const READ_ALOUD_I18N_KEYS = { + reasons: [...Object.values(REASON_KEYS), NO_EPUB_WITH_EXISTING_KEY, UNKNOWN_REASON_KEY], + errors: Object.values(ERROR_KEYS), + stages: [...Object.values(STAGE_KEYS), 'book.detail.files.readAloud.stages.working'], + stageUnits: [...Object.values(STAGE_UNIT_KEYS), 'book.detail.files.readAloud.stageDetail.step'], + devices: [...Object.values(DEVICE_KEYS), 'book.detail.files.readAloud.devices.other'], + chapterStatuses: [...Object.values(CHAPTER_STATUS_KEYS), OUTSIDE_NARRATION_STATUS_KEY, 'book.detail.files.readAloud.review.status.unknown'], + events: [...[...EVENT_CODES].map((code) => `${EVENTS}.${code}`), `${EVENTS}.stage_finished_plain`, 'book.detail.files.readAloud.log.other'], + notifications: [ + 'readyTitle', + 'needsReviewTitle', + 'needsReviewMessage', + 'failedTitle', + 'reviewExpiringTitle', + 'reviewExpiringMessage', + 'untitledBook', + ].map((key) => `${NOTIFICATIONS}.${key}`), +} diff --git a/client/src/lib/__tests__/socket.spec.ts b/client/src/lib/__tests__/socket.spec.ts index 92ae0148a..6efe74980 100644 --- a/client/src/lib/__tests__/socket.spec.ts +++ b/client/src/lib/__tests__/socket.spec.ts @@ -5,22 +5,35 @@ import { getValidToken, refreshAccessToken } from '@/lib/api' const listeners = new Map void)[]>() const connect = vi.fn<() => void>() +const disconnect = vi.fn<() => void>() const ioOptions: Record[] = [] -const fakeSocket = { - on(event: string, handler: (...args: unknown[]) => void) { - const existing = listeners.get(event) ?? [] - existing.push(handler) - listeners.set(event, existing) - return fakeSocket - }, - connect, +/** A fresh fake per socket, since the helper decorates the instance it is given. */ +function makeFakeSocket() { + const socket = { + on(event: string, handler: (...args: unknown[]) => void) { + const existing = listeners.get(event) ?? [] + existing.push(handler) + listeners.set(event, existing) + return socket + }, + connect, + disconnect() { + disconnect() + return socket + }, + // As in socket.io: an alias that goes through whatever `disconnect` the instance now has. + close() { + return socket.disconnect() + }, + } + return socket } vi.mock('socket.io-client', () => ({ io: (_namespace: string, options: Record) => { ioOptions.push(options) - return fakeSocket + return makeFakeSocket() }, })) @@ -45,6 +58,7 @@ describe('createAuthenticatedSocket', () => { listeners.clear() ioOptions.length = 0 connect.mockClear() + disconnect.mockClear() vi.mocked(getValidToken).mockResolvedValue('fresh-token') vi.mocked(refreshAccessToken).mockResolvedValue('fresh-token') }) @@ -153,6 +167,58 @@ describe('createAuthenticatedSocket', () => { expect(connect).toHaveBeenCalledTimes(2) }) + it('does not reopen a socket its owner closed while a retry was pending', async () => { + const socket = createAuthenticatedSocket('/read-aloud') + + rejectHandshake() + socket.disconnect() + await vi.advanceTimersByTimeAsync(60_000) + + expect(disconnect).toHaveBeenCalledTimes(1) + expect(refreshAccessToken).not.toHaveBeenCalled() + expect(connect).not.toHaveBeenCalled() + }) + + it('does not reopen a socket its owner closed while the token was refreshing', async () => { + let refreshed!: (token: string) => void + vi.mocked(refreshAccessToken).mockReturnValueOnce(new Promise((resolve) => (refreshed = resolve))) + const socket = createAuthenticatedSocket('/read-aloud') + + rejectHandshake() + await vi.advanceTimersByTimeAsync(1_000) + expect(refreshAccessToken).toHaveBeenCalledTimes(1) + + socket.close() + refreshed('fresh-token') + await vi.advanceTimersByTimeAsync(60_000) + + expect(connect).not.toHaveBeenCalled() + }) + + it('retries again once its owner reconnects a closed socket', async () => { + const socket = createAuthenticatedSocket('/read-aloud') + rejectHandshake() + socket.disconnect() + + fire('connect') + rejectHandshake() + await vi.advanceTimersByTimeAsync(2_000) + + expect(connect).toHaveBeenCalledTimes(1) + }) + + it('retries again when its owner reopens a closed socket that is then refused', async () => { + const socket = createAuthenticatedSocket('/read-aloud') + socket.disconnect() + + socket.connect() + rejectHandshake() + await vi.advanceTimersByTimeAsync(2_000) + + expect(refreshAccessToken).toHaveBeenCalledTimes(1) + expect(connect).toHaveBeenCalledTimes(2) + }) + it('does not reset the backoff for a connection the server closes immediately', async () => { createAuthenticatedSocket('/notifications') diff --git a/client/src/lib/socket.ts b/client/src/lib/socket.ts index d1bc42751..e10a92447 100644 --- a/client/src/lib/socket.ts +++ b/client/src/lib/socket.ts @@ -34,6 +34,8 @@ export function createAuthenticatedSocket(namespace: string, options: Partial | null = null let stableTimer: ReturnType | null = null let rejectedForAuth = false + /** Set when the owner closes the socket; a retry already in flight must not reopen it. */ + let closedByOwner = false function clearStableTimer(): void { if (stableTimer === null) return @@ -41,14 +43,22 @@ export function createAuthenticatedSocket(namespace: string, options: Partial= REAUTH_MAX_ATTEMPTS) return + if (closedByOwner || retryTimer !== null || attempts >= REAUTH_MAX_ATTEMPTS) return const delay = Math.min(REAUTH_BASE_DELAY_MS * 2 ** attempts, REAUTH_MAX_DELAY_MS) attempts += 1 retryTimer = setTimeout(() => { retryTimer = null void refreshAccessToken().then( - () => socket.connect(), + () => { + if (!closedByOwner) socket.connect() + }, // A refresh that fails here may be the session ending or the server being briefly // unreachable. Back off and try again; `api()` owns the redirect to login either way. () => scheduleReauth(), @@ -56,11 +66,25 @@ export function createAuthenticatedSocket(namespace: string, options: Partial { + closedByOwner = true + clearRetryTimer() + clearStableTimer() + return disconnect.call(socket) + } + const connect = socket.connect + socket.connect = () => { + closedByOwner = false + return connect.call(socket) + } + socket.on('connect', () => { - if (retryTimer !== null) { - clearTimeout(retryTimer) - retryTimer = null - } + closedByOwner = false + clearRetryTimer() clearStableTimer() stableTimer = setTimeout(() => { attempts = 0 diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 1040a6538..d846552ee 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -2048,7 +2048,7 @@ "overwriteBody": "Replace whenever a provider has data." }, "summary": "Providers are tried top to bottom and the first one that returns a value wins. The merge strategy decides whether that value may replace what the book already has; genres can also append new unique values. Set defaults here, then override individual fields per library.", - "summaryByMedium": "Providers are tried top to bottom, and the first one with a value wins; genres in merge mode and community ratings combine every provider instead. The merge strategy decides whether a value may replace what the book already has. Audible, AudNexus and Libro.fm only answer for books with audio, read-along EPUBs included, so putting them first prefers audiobook metadata without touching ebook-only books. Cover art of the wrong shape fills a slot only when nothing else can. A library override replaces that field's whole rule." + "summaryByMedium": "Providers are tried top to bottom, and the first one with a value wins; genres in merge mode and community ratings combine every provider instead. The merge strategy decides whether a value may replace what the book already has. Audible, AudNexus and Libro.fm only answer for books with audio, read-aloud EPUBs included, so putting them first prefers audiobook metadata without touching ebook-only books. Cover art of the wrong shape fills a slot only when nothing else can. A library override replaces that field's whole rule." }, "libraryOverrides": { "title": "Library Overrides", @@ -6021,7 +6021,7 @@ "opus": "Opus audio", "ogg": "OGG audio", "flac": "FLAC audio", - "readAlong": "Read-along EPUB", + "readAlong": "Read-aloud EPUB", "primary": "Primary format" }, "actions": { @@ -6045,7 +6045,7 @@ "sendViaEmail": "Send via Email", "rate": "Rate {star}", "openAs": "Open as {format}", - "openReadAlong": "Open read-along EPUB" + "openReadAlong": "Open read-aloud EPUB" }, "refreshMetadata": { "success": "Metadata refreshed", @@ -6530,6 +6530,20 @@ }, "editCover": "Edit cover", "viewCover": "View larger cover", + "coverStack": { + "show": { + "ebook": "Show the book cover", + "audio": "Show the audiobook cover" + }, + "front": { + "ebook": "Showing the book cover", + "audio": "Showing the audiobook cover" + } + }, + "readAloudInvite": { + "chip": "Read-aloud", + "title": "Create a read-aloud from this book's EPUB and audiobook" + }, "finished": "Finished", "resetFileProgress": "Reset file progress", "resetEditionProgress": "Reset progress for {format}", @@ -6554,14 +6568,15 @@ "unavailable": "Sync issue" }, "chipAria": "Read-aloud progress sync: {status}", + "noneYet": "No read-aloud EPUB yet. Create one to sync positions between the audiobook and the text.", "enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.", "disabledDescription": "Each format keeps its own reading position.", "epubCopiesDescription": "Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies. Add matching audiobook files to sync the audiobook too.", "reason": { - "no_media_overlay_epub": "A read-along EPUB with media overlays is required.", + "no_media_overlay_epub": "A read-aloud EPUB with media overlays is required.", "no_audio_files": "Matching standalone audiobook files are required.", "missing_duration": "Audio duration metadata is incomplete.", - "durationMismatch": "Durations do not match closely enough: audiobook {audio}, read-along EPUB {overlay}." + "durationMismatch": "Durations do not match closely enough: audiobook {audio}, read-aloud EPUB {overlay}." }, "enable": "Enable", "disable": "Disable", @@ -7074,6 +7089,287 @@ }, "files": { "title": "Book files", + "readAloud": { + "action": "Create read-aloud", + "loadFailed": "Read-aloud availability couldn't be checked.", + "loadFailedAgain": "{count, plural, one {Read-aloud availability still couldn't be checked after # retry.} other {Read-aloud availability still couldn't be checked after # retries.}}", + "checkAgain": "Check again", + "checking": "Checking…", + "reasons": { + "worker_unavailable": "The read-aloud worker is offline right now.", + "worker_incompatible": "The read-aloud worker needs an update to work with this server.", + "book_missing": "Restore this book's missing files to create a read-aloud.", + "no_epub": "Add a plain EPUB to this book to create a read-aloud.", + "no_epub_existing": "This book already has a read-aloud and no plain EPUB to rebuild it from.", + "too_many_audio_files": "This audiobook is split into more files than a read-aloud can be made from.", + "epub_not_allowed": "This library doesn't accept EPUB files, so a read-aloud can't be added.", + "library_not_mounted": "The read-aloud worker can't reach this library's folder.", + "job_active": "A read-aloud is already being created for this book.", + "unknown": "A read-aloud can't be created for this book right now." + }, + "dialog": { + "title": "Create a read-aloud", + "description": "Matches the narration to the ebook text and adds an EPUB that highlights along with the audio.", + "ebook": "Ebook", + "ebookHint": "Pick the plain EPUB that matches the narration.", + "existing": { + "title": "{count, plural, one {This book already has a read-aloud} other {This book already has # read-alouds}}", + "replace": "Replace {filename}", + "keep": "{count, plural, one {Keep both} other {Keep all of them}}", + "keepHint": "The new read-aloud is added as another file.", + "replaceHint": "The old file is replaced only after the new one passes its checks, and reading positions carry over.", + "replaceNotPermitted": "Replacing a read-aloud needs permission to delete books, so the new one is added as another file.", + "generated": "Made by BookOrbit", + "imported": "Imported" + }, + "audio": "Audiobook", + "audioSummary": "{count, plural, one {# track} other {# tracks}}, {order, select, book {in book order} other {in your order}}", + "audioSummaryWithDuration": "{count, plural, one {# track} other {# tracks}}, {duration}, {order, select, book {in book order} other {in your order}}", + "audioSkipped": "{count, plural, one {# track skipped} other {# tracks skipped}}", + "changeOrder": "Change order", + "restoreOrder": "Restore book order", + "trackList": "Tracks in listening order", + "trackLabel": "Track {position} of {total}: {name}", + "moveUp": "Move {name} up", + "moveDown": "Move {name} down", + "moved": "{name} moved to position {position} of {total}", + "skipped": "Skipped", + "noTracks": "Select at least one track.", + "language": "Narration language", + "chooseLanguage": "Choose a language", + "languageUnsupported": "{language} isn't supported by the speech model.", + "advanced": "Advanced", + "granularity": "Highlighting", + "sentence": "Sentence", + "sentenceHint": "Highlights one sentence at a time. Recommended.", + "word": "Word", + "wordHint": "Highlights each word. Takes longer and makes a larger file.", + "experimental": "Experimental", + "model": "Speech model: {model}", + "estimatedSize": "Estimated size: {size}", + "lowDiskLabel": "Low disk space.", + "lowDisk": "This needs about {needed} of working space on the worker, which has {free} free.", + "processingNote": "Runs in the background. Long audiobooks can take hours, and the first run for a language downloads its model.", + "start": "Start", + "starting": "Starting…" + }, + "devices": { + "cpu": "Runs on the CPU", + "mlx": "Runs on Apple Silicon (MLX)", + "cuda": "Runs on an NVIDIA GPU (CUDA)", + "webgpu": "Runs on the GPU (WebGPU)", + "other": "Runs on {device}" + }, + "job": { + "heading": "Read-aloud", + "startedBy": "Started by {name}", + "startedByUnknown": "Started by another user", + "dismiss": "Dismiss", + "details": "Details", + "queuedTitle": "Waiting to start", + "queueAhead": "{count, plural, =0 {Next in line} one {# job ahead} other {# jobs ahead}}", + "elapsed": "Running for {duration}", + "justStarted": "Just started", + "firstRunForLanguage": "First run for this language", + "stoppingTitle": "Stopping…", + "stoppingDescription": "Finishing the current step, then it stops. Nothing will be added to this book.", + "reviewTitle": "Ready for review", + "reviewDescription": "{percent} of the text matched the narration. Check the chapters before adding it.", + "reviewDescriptionPlain": "Check the chapters before adding it to this book.", + "reviewExpires": "Discarded automatically {when} unless you review it.", + "completeTitle": "Read-aloud ready", + "completeWarningsTitle": "Read-aloud ready, with warnings", + "completeAdded": "Added to this book as a new EPUB.", + "completeReplaced": "Replaced the previous read-aloud. Reading positions carry over.", + "matchedSentences": "{matched} of {total} {count, plural, one {sentence} other {sentences}} matched the narration", + "audioUsed": "{percent} of the audio used", + "outsideNarration": "Not in the narration, so left without audio: {chapters}.", + "outsideMore": "{count, plural, one {# more chapter} other {# more chapters}}", + "chaptersHeading": "Chapters", + "activityHeading": "Activity", + "failedTitle": "Read-aloud failed", + "retryUntil": "Retry continues from the last finished step until {date}.", + "cancelledTitle": "Generation cancelled", + "discardedTitle": "Output discarded", + "nothingAdded": "Nothing was added to this book.", + "expiredDescription": "It wasn't reviewed within 14 days, so nothing was added to this book.", + "progress": "Read-aloud progress", + "steps": "Steps", + "stepDone": "{step}, done", + "stepCurrent": "{step}, in progress", + "stepUpcoming": "{step}, not started", + "openReadAlong": "Open read-aloud", + "showFile": "Show file", + "retry": "Retry", + "startOver": "Start over", + "cancel": "Cancel generation", + "cancelPrompt": "Cancel generation?", + "keepRunning": "Keep running", + "warnings": "Warnings", + "live": "Read-aloud: {status}" + }, + "stageDetail": { + "file": "File {current} of {total}", + "track": "Track {current} of {total}", + "chapter": "Chapter {current} of {total}", + "step": "Step {current} of {total}" + }, + "stages": { + "queued": "Waiting to start", + "preparing_audio": "Preparing the audio", + "downloading_model": "Downloading the speech model", + "generating_emissions": "Listening to the narration", + "marking_up_ebook": "Marking up the ebook text", + "aligning": "Matching the text to the narration", + "validating": "Checking the result", + "awaiting_review": "Waiting for review", + "publishing": "Adding the read-aloud to this book", + "completed": "Done", + "working": "Working" + }, + "steps": { + "audio": "Audio", + "speech": "Speech", + "text": "Text", + "align": "Align", + "check": "Check", + "publish": "Publish" + }, + "review": { + "heading": "Chapters", + "summary": "{count, plural, one {# chapter} other {# chapters}}, {attention, plural, =0 {none need a look} one {# needs a look} other {# need a look}}", + "chapter": "Chapter", + "part": "Part", + "statusLabel": "Status", + "status": { + "aligned": "Aligned", + "notFound": "Not found in the audio", + "tooShort": "Too short to match", + "excluded": "Excluded", + "audioOnly": "Audio only", + "outside": "Not in the narration", + "unknown": "Unknown" + }, + "matched": "Matched", + "matchedValue": "{percent} matched", + "zones": { + "front": "Front matter", + "body": "Body", + "back": "Back matter" + }, + "include": "Include {title}", + "showText": "Show text from {title}", + "hideText": "Hide text from {title}", + "preview": "Preview", + "firstMatch": "First match", + "lastMatch": "Last match", + "noText": "No text to show for this chapter.", + "noReport": "No chapter report was kept for this job. Run it again to see one.", + "loading": "Loading chapters…", + "loadFailed": "The chapter report couldn't be loaded.", + "tryAgain": "Try again", + "pending": "{count, plural, one {# change is not applied yet.} other {# changes are not applied yet.}} Re-sync reruns only the matching step.", + "resync": "Re-sync", + "publishAnyway": "Publish anyway", + "discard": "Discard", + "discardPrompt": "Discard this output?", + "keep": "Keep it" + }, + "log": { + "heading": "Activity", + "empty": "No activity yet.", + "loading": "Loading activity…", + "loadFailed": "The activity log couldn't be loaded.", + "tryAgain": "Try again", + "warn": "Warning", + "error": "Error", + "other": "Worker activity", + "events": { + "job_created": "Job created", + "claimed": "Sent to the worker", + "job_accepted": "The worker accepted the job", + "disk_checked": "Enough free space on the worker", + "disk_check_failed": "Not enough free space on the worker", + "stage_started": "Started", + "stage_finished": "Finished in {duration}", + "stage_finished_plain": "Finished", + "stage_skipped": "Reused earlier work", + "stage_failed": "This step failed", + "model_download_started": "Downloading the speech model for this language", + "model_download_finished": "Speech model downloaded", + "emissions_cached": "Reused speech analysis from an earlier run", + "audio_reencoded_cbr": "Converted variable-bitrate MP3 for accurate timing", + "stalign_warning": "The aligner reported a warning", + "waiting_for_worker": "Waiting for the worker to respond", + "worker_reachable_again": "The worker is responding again", + "worker_lost_job": "The worker restarted; resuming from earlier work", + "requeued": "Will try again shortly", + "recovered": "Resuming after a server restart", + "output_validated": "Checked the result", + "needs_review": "Needs review: part of the text didn't match the audio", + "publish_requested": "Adding to the book", + "published": "Added to the book", + "file_added": "Saved as a new file", + "file_replaced": "Replaced the existing read-aloud", + "file_already_attached": "The book already had this file", + "replace_target_missing": "The read-aloud to replace was gone, so a new file was added", + "replace_not_permitted": "Replacing the read-aloud was no longer permitted, so a new file was added", + "job_completed": "The worker finished", + "cancel_requested": "Cancellation requested", + "runner_killed": "Stopped the worker process", + "job_cancelled": "The worker stopped", + "cancelled": "Cancelled", + "discarded": "Discarded at review", + "job_failed": "The worker reported a failure", + "retry_requested": "Retry requested", + "resync_requested": "Re-sync requested", + "review_reminder_sent": "Reminder sent: the review expires soon", + "review_expired": "Discarded after 14 days without review" + } + }, + "warnings": { + "unaligned_tracks": "One or more audio tracks could not be aligned.", + "unmatched_text": "A noticeable amount of ebook text was not matched to narration.", + "interpolated_text": "Some text timing was estimated between confident matches.", + "dropped_text": "Some ebook text was omitted from the synchronized sequence." + }, + "errors": { + "create": "The read-aloud couldn't be started. Try again.", + "createInvalid": "Some selected files are no longer on this book. Check the selection and try again.", + "createConflict": "Another read-aloud is already being created for this book.", + "createUnavailable": "The read-aloud worker is offline. Try again later.", + "forbidden": "You don't have permission to do that.", + "gone": "This job no longer exists.", + "conflict": "This job had already moved on. It now shows where it is.", + "cancel": "The job couldn't be cancelled. Try again.", + "publish": "The read-aloud couldn't be added to this book. Try again.", + "retry": "The job couldn't be retried. Try again.", + "resync": "The re-sync couldn't be started. Try again.", + "dismiss": "The card couldn't be dismissed. Try again.", + "job": { + "worker_unavailable": "The read-aloud worker couldn't be reached.", + "worker_incompatible": "The read-aloud worker needs an update to work with this server.", + "worker_crashed": "The read-aloud worker stopped unexpectedly.", + "library_not_mounted": "The read-aloud worker can't reach this library's folder.", + "source_missing": "A selected file is no longer available.", + "source_changed": "A selected file changed after the job started.", + "invalid_source": "The selected EPUB or audiobook couldn't be read.", + "unsupported_language": "The speech model doesn't support the selected language.", + "insufficient_storage": "There isn't enough free disk space to finish.", + "audio_processing_failed": "The audiobook couldn't be prepared.", + "model_download_failed": "The speech model couldn't be downloaded.", + "inference_failed": "Speech recognition failed while listening to the narration.", + "markup_failed": "The ebook text couldn't be prepared for matching.", + "alignment_failed": "The text couldn't be matched to the narration.", + "invalid_output": "The generated EPUB failed its checks.", + "publication_failed": "The generated EPUB couldn't be added to this book.", + "review_expired": "It wasn't reviewed within 14 days.", + "discarded": "The read-aloud was discarded at review.", + "cancelled": "The job was cancelled.", + "unknown": "Something went wrong while creating the read-aloud." + } + } + }, "stats": { "files": "Files", "total": "Total", @@ -9987,7 +10283,7 @@ "unconfirmed": "unconfirmed", "sending": "sending", "narration": "Narration", - "narrationTitle": "Read-along books keep their narration" + "narrationTitle": "Read-aloud books keep their narration" }, "device": { "kindle": "Kindle", @@ -10012,7 +10308,7 @@ "total": "{size} in total", "send": "{count, plural, one {Send} other {Send # emails}}", "noFormat": "No {format}, gets {got}", - "readAlong": "Read-along, {size}", + "readAlong": "Read-aloud, {size}", "overKindle": "{size}, over Kindle's 50 MB limit", "overGmail": "{size}, over Gmail's 25 MB limit", "kindleRejects": "Kindle rejects {format}", @@ -10049,7 +10345,7 @@ "formatHint": "A book without this format gets its primary edition.", "kindleFormatHint": "Amazon stopped accepting MOBI and AZW3 by email in 2023, so they are not offered for a Kindle. A book without this format gets its primary edition.", "kindleLegacyFormat": "Kindle no longer accepts {format} by email. Pick EPUB or PDF.", - "readAlong": "Read-along books", + "readAlong": "Read-aloud books", "readAlongAutomatic": "Automatic", "readAlongAutomaticHint": "Text-only when a safe copy can be made, otherwise the original.", "readAlongTextOnly": "Text-only EPUB", @@ -10756,9 +11052,9 @@ "toggleAria": "Write EPUB metadata" }, "readAlong": { - "title": "Read-along EPUB", - "hint": "EPUBs with synced narration, such as Storyteller read-alongs. They carry their audio, so they are large and need more memory to rewrite.", - "toggleAria": "Write read-along EPUB metadata" + "title": "Read-aloud EPUB", + "hint": "EPUBs with synced narration, such as Storyteller read-alouds. They carry their audio, so they are large and need more memory to rewrite.", + "toggleAria": "Write read-aloud EPUB metadata" }, "fb2": { "title": "FictionBook (FB2)", @@ -11467,6 +11763,15 @@ "dismiss": "Dismiss notification", "occurrences": "Happened {count} times", "occurrencesShort": "x{count}", + "readAloud": { + "readyTitle": "Read-aloud ready: {title}", + "needsReviewTitle": "Read-aloud needs review: {title}", + "needsReviewMessage": "Part of the text could not be matched to the audio. Review it before adding it to the book.", + "failedTitle": "Read-aloud failed: {title}", + "reviewExpiringTitle": "Read-aloud review expires soon: {title}", + "reviewExpiringMessage": "The generated read-aloud will be discarded in a few days unless you review it.", + "untitledBook": "Untitled book" + }, "preferences": { "title": "Notifications", "subtitle": "Choose which notification categories you want to receive.", @@ -11524,6 +11829,10 @@ "label": "Bulk rename", "description": "When a bulk rename or a move of books between libraries completes or fails." }, + "readAloud": { + "label": "Read-aloud generation", + "description": "When a read-aloud you started is ready, needs your review, or fails." + }, "migration": { "label": "Data migration", "description": "When an import from another library tool completes or fails." diff --git a/client/src/views/BookFilesView.vue b/client/src/views/BookFilesView.vue index cba561acb..4b171cb35 100644 --- a/client/src/views/BookFilesView.vue +++ b/client/src/views/BookFilesView.vue @@ -21,11 +21,15 @@ const pageTitle = computed(() => { usePageTitle(pageTitle) watch(bookId, (id) => fetch(id), { immediate: true }) + +function handleRefetch(): void { + void fetch(bookId.value) +}