Compare commits

...

41 Commits

Author SHA1 Message Date
Admin
3154a22500 fix(errors): redesign all Caddy error pages with consistent branded layout
Some checks failed
CI / UI (push) Successful in 43s
Release / Test backend (push) Successful in 45s
CI / Backend (push) Successful in 48s
Release / Check ui (push) Successful in 36s
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 36s
Release / Docker / caddy (push) Failing after 1m50s
Release / Docker / runner (push) Successful in 1m57s
Release / Docker / backend (push) Successful in 3m13s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Has been skipped
Add header/footer, LibNovel wordmark, pulsing status indicator, faint
watermark code, and auto-refresh countdown (20s for 502/504, 30s for 503).
Fix two-tone background by setting background on html+body. 404 is static
with no auto-refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:22:14 +05:00
Admin
61e0d98057 feat(ui): declutter header — user menu + lang dropdown, mobile theme/lang
Some checks failed
CI / Backend (pull_request) Successful in 55s
CI / UI (pull_request) Successful in 49s
CI / UI (push) Failing after 35s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 57s
Release / Check ui (push) Successful in 36s
Release / Docker / caddy (push) Successful in 1m11s
Release / Docker / runner (push) Successful in 2m5s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Failing after 2m32s
Release / Gitea Release (push) Has been skipped
Desktop header before: logo · Library · Catalogue · Feedback · ●●● · EN RU ID PT-BR FR · Admin · username · Sign out
Desktop header after:  logo · Library · Catalogue · Feedback · ●●● · [🌐 EN ▾] · [avatar ▾]

Changes:
- Theme dots kept but made slightly smaller (3.5 → 3.5, less gap)
- 5 bare language codes replaced with a compact globe + current locale
  dropdown (click to expand all 5 options)
- Admin link, username, and Sign out collapsed into a single user-initial
  avatar dropdown (Profile / Admin panel / Sign out)
- Click-outside overlay closes any open dropdown
- Mobile drawer: added Theme and Language rows so users can switch both
  without opening the desktop header
- Footer locale switcher removed (redundant with header and mobile drawer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:54:46 +05:00
Admin
601c26d436 fix(ui): theme token consistency, seek bar a11y, colour polish
All checks were successful
CI / Backend (push) Successful in 36s
CI / Backend (pull_request) Successful in 46s
CI / UI (push) Successful in 59s
CI / UI (pull_request) Successful in 37s
- Add --color-success variable to all three themes
- Replace hard-coded amber/green Tailwind colours with CSS variables:
    Pro badge + discount badge: bg-amber-400/15 → bg-(--color-brand)/15
    Saved confirmation:         text-green-400 → text-(--color-success)
    Catalogue flash messages:   emerald/yellow → success/brand tokens
    Login hover border:         border-zinc-600 → border-(--color-brand)/50
- Seek bar in mini-player now has aria-label (player_seek_label)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:24:34 +05:00
Admin
4a267d8fd8 feat: open catalogue + book pages to public (hybrid open model)
All checks were successful
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 44s
CI / Backend (push) Successful in 44s
Release / Test backend (push) Successful in 28s
CI / UI (push) Successful in 1m12s
Release / Check ui (push) Successful in 56s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / ui (push) Successful in 1m57s
Release / Docker / runner (push) Successful in 3m45s
Release / Docker / backend (push) Successful in 3m50s
Release / Gitea Release (push) Successful in 14s
Reading content is now publicly accessible without login:
- /catalogue, /books/[slug], /books/[slug]/chapters, /books/[slug]/chapters/[n]
  are all public — no login redirect
- /books (personal library), /profile, /admin remain protected

Unauthenticated UX:
- Chapter page: "Audio narration available — Sign in to listen" banner
  replaces the audio player for logged-out visitors
- Book detail: bookmark icon links to /login instead of triggering
  the save action (both desktop and mobile CTAs)

SEO:
- robots.txt updated: Allow /books/ and /catalogue, Disallow /books (library)
- sitemap.xml now includes /catalogue as primary crawl entry point

i18n: added reader_signin_for_audio, reader_signin_audio_desc,
      book_detail_signin_to_save in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:16:22 +05:00
Admin
c9478a67fb fix(ci): use full gitea.com URL for gitea-release-action
All checks were successful
CI / UI (push) Successful in 41s
CI / Backend (push) Successful in 50s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 34s
CI / Backend (pull_request) Successful in 46s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 3m29s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / caddy (push) Successful in 1m17s
Release / Gitea Release (push) Successful in 17s
Bare `actions/` references resolve to github.com by default in act_runner.
gitea-release-action lives on gitea.com so must use the full https:// URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:38:36 +05:00
Admin
1b4835daeb fix(homelab): switch Fider SMTP to port 587 + STARTTLS
All checks were successful
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 1m10s
Port 465 (SMTPS) is blocked on the homelab server. Port 587 with STARTTLS
works. Updated FIDER_SMTP_PORT=587 and FIDER_SMTP_ENABLE_STARTTLS=true in
Doppler prd_homelab, and made EMAIL_SMTP_ENABLE_STARTTLS dynamic so it reads
from Doppler instead of being hardcoded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:16:01 +05:00
Admin
c9c12fc4a8 fix(infra): correct doppler entrypoint for watchtower container
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 45s
- Fix binary path: /usr/bin/doppler (not /usr/local/bin)
- Mount /root/.doppler config so the container can auth without DOPPLER_TOKEN env
- Set HOME=/root so doppler locates the mounted config directory
- Add explicit --project/--config flags to override directory-scope lookup
- Production: --project libnovel --config prd
- Homelab: --project libnovel --config prd_homelab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:05:33 +05:00
Admin
dd35024d02 chore(infra): run watchtower via doppler for fresh secrets on restart
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 47s
Mount the host doppler binary into the watchtower container and use it as
the entrypoint so WATCHTOWER_NOTIFICATION_URL and other secrets are fetched
from Doppler each time the container starts, rather than being baked in at
compose-up time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:52:32 +05:00
Admin
4b8104f087 feat(ui): language persistence, theme fix, font/size settings, header quick-access
Some checks failed
CI / UI (push) Failing after 54s
CI / Backend (push) Successful in 1m56s
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 54s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 2m3s
Release / Gitea Release (push) Failing after 2s
- Fix language not persisting after refresh: save locale in user_settings,
  set PARAGLIDE_LOCALE cookie from DB preference on server load
- Fix theme change not applying: context setter was mismatched (setTheme vs current)
- Add font family (system/serif/mono) and text size (sm/md/lg/xl) user settings
  stored in DB and applied via CSS custom properties
- Add theme color dots and language picker to desktop header for quick access
- Footer locale switcher now saves preference to DB before switching
- Remove change password section (OAuth-only, no password login)
- Fix active sessions piling up: reuse existing session on re-login via OAuth
- Extend speed step cycle to include 2.5× and 3.0× (matching profile slider)
- Replace plain checkbox with modern toggle switch for auto-next setting
- Fix catalogue status labels (ongoing/completed) to use translation keys
- Add font family and text size translation keys to all 5 locale files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:43:00 +05:00
Admin
5da880d189 fix(runner): add heartbeat + translation polling to asynq mode
All checks were successful
CI / UI (push) Successful in 57s
CI / Backend (pull_request) Successful in 1m4s
CI / Backend (push) Successful in 1m45s
CI / UI (pull_request) Successful in 51s
Two bugs prevented asynq mode from working correctly on the homelab runner:

1. No healthcheck file: asynq mode never writes /tmp/runner.alive, so
   Docker healthcheck always fails. Added heartbeat goroutine that
   writes the file every StaleTaskThreshold (30s).

2. Translation tasks not dispatched: translation uses ClaimNextTranslationTask
   (PocketBase poll queue), not Redis/asynq. Audio + scrape use asynq mux,
   but translation sits in PocketBase forever. Added pollTranslationTasks()
   goroutine that polls PocketBase on the same PollInterval as the old
   poll() loop.

All Go tests pass (go test ./... in backend/).
2026-03-29 20:22:37 +05:00
Admin
98631df47a feat(billing): Polar.sh Pro subscription integration
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
  subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
  counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
  or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
2026-03-29 13:21:23 +05:00
Admin
83b3dccc41 fix(ui): run paraglide compile in prepare so CI type-check finds $lib/paraglide/server
Some checks failed
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 1m0s
CI / UI (push) Successful in 42s
Release / Test backend (push) Successful in 46s
CI / Backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 34s
Release / Docker / caddy (push) Successful in 1m4s
Release / Docker / runner (push) Successful in 2m7s
Release / Docker / ui (push) Successful in 2m26s
Release / Docker / backend (push) Successful in 3m28s
Release / Gitea Release (push) Failing after 2s
The paraglide output dir has its own .gitignore (ignores everything), so
generated files are never committed. CI ran `npm ci` then `svelte-check`
without ever invoking vite, so $lib/paraglide/server was missing at
type-check time.

Adding `paraglide-js compile` to the prepare script ensures the output is
regenerated during `npm ci` before svelte-kit sync and svelte-check run.

Also adds translation_jobs collection to pb-init-v3.sh.
2026-03-29 11:50:06 +05:00
Admin
588e455aae chore(homelab): add LibreTranslate service to runner compose
Some checks failed
CI / Backend (pull_request) Failing after 11s
CI / UI (pull_request) Failing after 11s
- libretranslate/libretranslate:latest, internal Docker network only
- LT_LOAD_ONLY=en,ru,id,pt,fr (only pairs the runner needs)
- LT_API_KEYS=true, key stored in Doppler prd_homelab
- Runner depends_on libretranslate (service_healthy)
- LIBRETRANSLATE_URL=http://libretranslate:5000 (no tunnel needed)
- RUNNER_MAX_CONCURRENT_TRANSLATION wired from Doppler
2026-03-29 11:42:52 +05:00
Admin
28ac8d8826 feat(translation): machine translation pipeline + admin bulk enqueue UI
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 51s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 55s
Release / Test backend (push) Failing after 1m9s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Docker / caddy (push) Failing after 28s
Release / Gitea Release (push) Has been skipped
CI / UI (pull_request) Failing after 42s
CI / Backend (pull_request) Successful in 3m45s
- LibreTranslate client (chunks on blank lines, ≤4500 chars, 3-goroutine semaphore)
- Runner translation task loop (OTel, heartbeat, MinIO storage)
- PocketBase translation_jobs collection support (create/claim/finish/list)
- Per-chapter language switcher on chapter reader (EN/RU/ID/PT/FR, polls until done)
- Admin /admin/translation page: bulk enqueue form + live-polling jobs table
- New backend routes: POST /api/translation/{slug}/{n}, GET /api/translation/status,
  GET /api/translation/{slug}/{n}, GET /api/admin/translation/jobs,
  POST /api/admin/translation/bulk
- ListTranslationTasks added to taskqueue.Reader interface + store impl
- All builds and tests pass; svelte-check: 0 errors
2026-03-29 11:32:42 +05:00
Admin
0a3a61a3ef feat(i18n): add Paraglide i18n with 5 locales (v2.3.22)
Some checks failed
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Failing after 27s
CI / UI (push) Failing after 27s
CI / Backend (push) Successful in 48s
Release / Test backend (push) Successful in 23s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Failing after 52s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
- Install @inlang/paraglide-js v2.15.1; configure project.inlang settings
- Add en/ru/id/pt-BR/fr message catalogues (~140 keys each)
- Wire paraglideVitePlugin in vite.config.ts, reroute hook in hooks.ts,
  and paraglideHandle middleware in hooks.server.ts
- Migrate all routes and shared components to use m.*() message calls
- Fix duplicate onMount body in chapters/[n]/+page.svelte
- Build passes; svelte-check: 0 errors, 3 pre-existing warnings
2026-03-29 10:43:53 +05:00
Admin
7a2a4fc755 feat(ui): theme system — amber/slate/rose, profile picker, full token migration
Some checks failed
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 25s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 44s
Release / Check ui (push) Successful in 24s
Release / Docker / caddy (push) Failing after 1m4s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Failing after 29s
Release / Docker / runner (push) Failing after 54s
Release / Gitea Release (push) Has been skipped
- Add CSS custom property token system in app.css (@theme + [data-theme] overrides)
- Three themes: amber (default), slate (indigo/dark), rose (dark pink)
- Flash prevention via inline <script> in <svelte:head> sets data-theme before paint
- Theme context (setContext/getContext) in +layout.svelte for live preview
- Theme persisted via PocketBase user_settings (PBUserSettings.theme field)
- /api/settings GET/PUT updated to handle theme field alongside existing settings
- Profile page: new Appearance section with 3 colour-swatch theme picker
- Full token migration across all 36 route/component files:
  zinc/amber hardcoded Tailwind classes → CSS var utilities (bg-(--color-surface), etc.)
- UI primitives (Badge, Button, Card, Dialog, Separator, Textarea) migrated
- accent-amber-400 replaced with inline style accent-color: var(--color-brand)
2026-03-28 23:57:16 +05:00
Admin
801928aadf fix(scraper): update status and genres selectors for current novelfire.net HTML
Some checks failed
CI / Backend (push) Failing after 11s
CI / UI (push) Successful in 48s
Release / Test backend (push) Successful in 50s
Release / Check ui (push) Successful in 55s
CI / UI (pull_request) Successful in 37s
Release / Docker / caddy (push) Successful in 47s
CI / Backend (pull_request) Successful in 46s
Release / Docker / runner (push) Successful in 2m37s
Release / Docker / ui (push) Successful in 2m42s
Release / Docker / backend (push) Successful in 3m13s
Release / Gitea Release (push) Failing after 2s
novelfire.net changed its book page structure. Old selectors produced empty
status and null genres for every book, causing all Meilisearch filters to
return zero results.

Old → new:
- status:  <span class="status">  →  <strong class="ongoing|completed|hiatus">
  (text lowercased for consistent index values)
- genres:  <div class="genres"> <a>  →  <div class="categories"> <a class="property-item">
  (text lowercased for consistent index values)

Adds TestParseMetadataSelectors to guard against future regressions.
2026-03-28 22:54:35 +05:00
Admin
040072c3f5 docs(d2): update architecture and api-routing diagrams to current state
All checks were successful
CI / Backend (pull_request) Successful in 33s
CI / UI (pull_request) Successful in 40s
architecture.d2:
- Split app into prod VPS (165.22.70.138) and homelab runner (192.168.0.109)
- Add CrowdSec, Dozzle agent, pocket-tts (voice samples)
- Valkey now shown as Asynq job queue in addition to presign cache
- Add caddy-l4 Redis TCP proxy (:6380) to Caddy label
- Add CI/CD node (Gitea Actions) with full job list incl. releases.json bake
- Remove runner from prod app group (it runs on homelab only)
- Watchtower: note runner is label-disabled on prod

api-routing.d2:
- Add /api/presign/* routes to backend (presign_be group)
- Add /api/audio POST + status GET to both sk and be
- Add /api/scrape/book and /api/scrape/book/range to scrape_sk
- Catalogue: annotate Meilisearch vs legacy browse
- Add Meilisearch filter/sort fields to storage node
- Add Asynq queue note to Valkey storage node
- Fix presign proxy: sk routes through be.presign_be, not directly to storage
2026-03-28 22:44:31 +05:00
Admin
6a76e97a67 fix(ci): follow HTTP redirect and validate JSON in fetch-releases step
Some checks failed
CI / Backend (push) Successful in 26s
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 28s
CI / UI (push) Successful in 1m4s
CI / UI (pull_request) Failing after 11s
CI / Backend (pull_request) Successful in 27s
Release / Docker / caddy (push) Successful in 1m3s
Release / Docker / runner (push) Failing after 1m3s
Release / Docker / ui (push) Successful in 1m53s
Release / Docker / backend (push) Failing after 4m2s
Release / Gitea Release (push) Has been skipped
curl -sf without -L silently wrote '301 Moved Permanently' to releases.json
instead of following the http→https redirect. Added -L to follow redirects,
set -euo pipefail, and jq type validation so the step fails hard on bad JSON.
2026-03-28 22:38:09 +05:00
Admin
71f79c8e02 feat(admin): bake changelog into UI image at CI build time
Some checks failed
CI / Backend (push) Failing after 11s
Release / Test backend (push) Successful in 34s
CI / UI (push) Successful in 48s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 52s
CI / Backend (pull_request) Successful in 42s
CI / UI (pull_request) Successful in 37s
Release / Docker / backend (push) Failing after 44s
Release / Docker / ui (push) Successful in 2m24s
Release / Docker / runner (push) Failing after 3m24s
Release / Gitea Release (push) Has been skipped
Replace runtime Gitea API fetch with fs.readFileSync of releases.json,
which CI writes to ui/static/ before the Docker build context is sent.
Eliminates prod→homelab network dependency for the changelog page.
2026-03-28 21:49:52 +05:00
Admin
5ee4a06654 feat(admin): compact scrape page layout; add Changelog page
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 35s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 36s
CI / Backend (pull_request) Successful in 41s
Release / Docker / caddy (push) Successful in 1m31s
CI / UI (pull_request) Successful in 49s
Release / Docker / backend (push) Failing after 1m9s
Release / Docker / ui (push) Successful in 2m18s
Release / Docker / runner (push) Successful in 3m49s
Release / Gitea Release (push) Has been skipped
- Scrape page: replace three large cards + genre card with a compact
  bordered table of rows (label + inline controls per action). Visually
  much lighter, all controls visible without scrolling.
- Admin sidebar: add Changelog link after Audio.
- New /admin/changelog page: fetches releases from Gitea API and renders
  them as a clean list (tag, title, date, body).
2026-03-28 21:41:13 +05:00
Admin
63b286d0a4 fix(caddy): move layer4 into global block; use :6380 listener address
Some checks failed
CI / Backend (push) Successful in 30s
CI / UI (push) Successful in 39s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 25s
CI / UI (pull_request) Successful in 25s
Release / Docker / caddy (push) Successful in 1m9s
CI / Backend (pull_request) Successful in 1m14s
Release / Docker / ui (push) Successful in 3m56s
Release / Docker / runner (push) Successful in 4m41s
Release / Docker / backend (push) Successful in 7m51s
Release / Gitea Release (push) Failing after 2s
The bare { } block at the bottom was a second global options block which
Caddy's caddyfile adapter rejects on reload. Merged layer4 into the single
top-level global block. Changed listener from hostname (redis.libnovel.cc:6380)
to :6380 so Caddy binds to the local interface rather than the Cloudflare IP
that resolves for the hostname.
2026-03-28 21:36:12 +05:00
Admin
d3f06c5c40 fix(caddy): add 404 error page; add health checks and lb_try_duration to ui upstream
Some checks failed
Release / Test backend (push) Successful in 26s
CI / Backend (push) Successful in 42s
CI / UI (push) Successful in 47s
Release / Check ui (push) Successful in 26s
CI / UI (pull_request) Successful in 26s
CI / Backend (pull_request) Successful in 44s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Failing after 1m4s
Release / Docker / runner (push) Failing after 1m3s
Release / Docker / ui (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped
- Add caddy/errors/404.html (matches existing 502/503/504 style)
- Add handle_errors 404 block in Caddyfile
- Add active health checks (5s interval) and lb_try_duration 3s to the
  ui reverse_proxy so Caddy detects Watchtower container replacements
  quickly and serves the 502 maintenance page instead of a raw error
2026-03-28 21:32:04 +05:00
Admin
e71ddc2f8b fix(backend): add ffmpeg to backend image for pocket-tts voice sample generation
Some checks failed
CI / Backend (push) Successful in 29s
CI / UI (push) Successful in 27s
Release / Test backend (push) Successful in 37s
CI / Backend (pull_request) Failing after 11s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 57s
CI / UI (pull_request) Successful in 56s
Release / Docker / runner (push) Failing after 1m22s
Release / Docker / backend (push) Failing after 1m46s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Has been skipped
handlePresignVoiceSample generates voice samples on demand via pocket-tts,
which requires WAV→MP3 transcoding via ffmpeg. The backend was using
distroless/static (no ffmpeg) so all pocket-tts preview requests returned 500.
Switch backend stage to Alpine + ffmpeg, matching the runner image.
2026-03-28 21:24:59 +05:00
Admin
b783dae5f4 refactor(admin): replace tab bar with sidebar layout
Some checks failed
CI / Backend (push) Successful in 38s
CI / UI (push) Successful in 42s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 28s
Release / Docker / caddy (push) Successful in 1m11s
CI / UI (pull_request) Successful in 29s
Release / Docker / ui (push) Successful in 1m54s
Release / Docker / runner (push) Successful in 4m27s
Release / Docker / backend (push) Failing after 5m11s
Release / Gitea Release (push) Has been skipped
Move admin navigation from a two-row tab strip into a persistent left
sidebar with grouped sections (Pages / Tools). Consolidate Scrape and
Audio entries in the global top nav into a single Admin link.
2026-03-28 21:15:49 +05:00
Admin
dcf40197d4 fix(ui): brighten footer link text for readability on dark background
Some checks failed
CI / Backend (push) Successful in 51s
CI / UI (push) Successful in 28s
Release / Test backend (push) Successful in 50s
Release / Docker / caddy (push) Successful in 1m0s
Release / Check ui (push) Successful in 1m15s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 32s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Successful in 4m9s
Release / Docker / runner (push) Successful in 5m46s
Release / Gitea Release (push) Failing after 1s
2026-03-28 21:12:46 +05:00
Admin
9dae5e7cc0 fix(infra): add POCKET_TTS_URL to backend and runner services
Some checks failed
CI / Backend (push) Successful in 27s
Release / Test backend (push) Successful in 39s
CI / UI (push) Successful in 48s
Release / Check ui (push) Successful in 25s
CI / UI (pull_request) Successful in 25s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m7s
Release / Docker / backend (push) Successful in 2m17s
Release / Docker / ui (push) Successful in 2m9s
Release / Docker / runner (push) Failing after 3m34s
Release / Gitea Release (push) Has been skipped
Backend was missing POCKET_TTS_URL entirely — pocketTTSClient was nil
so voices() only returned 67 Kokoro voices. Runner already had the var
via Doppler but it was absent from the compose environment block.

Also fix stray leading space on backend environment: key (YAML parse error).

Verified: /api/voices now returns 87 voices (67 kokoro + 20 pocket-tts).
2026-03-28 20:54:04 +05:00
Admin
908f5679fd fix(ui): defer catalogue filter navigation to explicit Apply button
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Successful in 34s
Release / Test backend (push) Successful in 52s
CI / UI (push) Successful in 55s
Release / Docker / caddy (push) Successful in 48s
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 43s
Release / Docker / runner (push) Failing after 38s
Release / Docker / ui (push) Successful in 1m55s
Release / Docker / backend (push) Successful in 3m30s
Release / Gitea Release (push) Has been skipped
Removed onchange→navigateWithFilters from all three selects — the
immediate navigation on every change was the root cause of both bugs:
1. Filters applied before the user finished selecting all options.
2. Svelte 5 bind:value updates state after onchange fires, so the
   navigateWithFilters call read stale values → wrong URL params → no results.

Renamed navigateWithFilters to applyFilters (no overrides arg needed).
Added an amber Apply button next to Reset; selects now only update local
state until the user presses Apply.
2026-03-28 19:44:36 +05:00
Admin
f75292f531 fix(homelab): add Google + GitHub OAuth env vars to Fider service
All checks were successful
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 38s
2026-03-28 19:38:11 +05:00
Admin
2cf0528730 fix(ui): show version+SHA+build time in footer; fix env not reaching runtime image
Some checks failed
CI / Backend (push) Successful in 51s
CI / UI (push) Successful in 27s
Release / Docker / caddy (push) Failing after 23s
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Successful in 39s
CI / Backend (pull_request) Successful in 28s
CI / UI (pull_request) Successful in 36s
Release / Docker / backend (push) Failing after 1m25s
Release / Docker / runner (push) Successful in 2m25s
Release / Docker / ui (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
PUBLIC_BUILD_VERSION and PUBLIC_BUILD_COMMIT were set only in the builder
stage ENV — they were never re-declared in the runtime stage, so the Node
server started with them undefined and the badge always showed 'dev'.

Fix: re-declare all three ARGs after the second FROM and set runtime ENVs.
Add PUBLIC_BUILD_TIME (ISO timestamp from gitea.event.head_commit.timestamp)
injected via build-arg in release.yaml. Badge now shows e.g.:
  v2.3.9+abc1234 · 28 Mar 2026 14:30 UTC
2026-03-28 19:33:49 +05:00
Admin
428b57732e fix(ui): resolve avatar URL from MinIO; fall back to OAuth provider URL
Some checks failed
CI / Backend (push) Successful in 50s
CI / UI (push) Successful in 34s
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 35s
CI / Backend (pull_request) Successful in 49s
CI / UI (pull_request) Successful in 40s
Release / Docker / backend (push) Failing after 52s
Release / Docker / runner (push) Failing after 58s
Release / Docker / ui (push) Failing after 55s
Release / Gitea Release (push) Has been skipped
Add resolveAvatarUrl(userId, storedValue) helper that tries MinIO first,
then falls back to the stored HTTP URL for OAuth users (Google/GitHub)
who have never uploaded a custom avatar.

Add getUserById() to pocketbase helpers for batch avatar resolution in
comments. Update all 6 call sites to use the new helper.
2026-03-28 19:23:30 +05:00
Admin
61e77e3e28 ci: remove Docker builds from CI; keep vet/build/test/type-check only
All checks were successful
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 37s
CI / UI (pull_request) Successful in 35s
CI / Backend (pull_request) Successful in 1m9s
Docker image builds belong to the release workflow (tag-triggered).
CI now runs: go vet, go build (backend/runner/healthcheck), go test,
svelte-check, and UI vite build — fast feedback without Docker overhead.
Also triggers on all branches, not just main/master.
2026-03-28 19:08:17 +05:00
Admin
b363c151a5 fix(ui): fix catalogue filters in Svelte 5; improve build badge visibility
Some checks failed
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Failing after 1m0s
CI / Test backend (pull_request) Successful in 40s
CI / Docker / caddy (pull_request) Failing after 21s
CI / Check ui (pull_request) Successful in 48s
Release / Docker / ui (push) Successful in 2m49s
Release / Docker / runner (push) Successful in 3m10s
Release / Docker / backend (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
CI / Docker / ui (pull_request) Successful in 1m31s
CI / Docker / backend (pull_request) Successful in 3m13s
CI / Docker / runner (pull_request) Successful in 3m59s
- Catalogue filter selects: replace value= initializer with bind:value +
  onchange goto() so filters navigate immediately on change (no Apply button)
- Add selected= on each <option> for correct DOM initialisation in Svelte 5
- Build badge: give distinct bg-zinc-800 pill, visible zinc-300/400/500 text
  instead of zinc-700/zinc-800 which blended into the footer background
2026-03-28 19:01:58 +05:00
Admin
aef9e04419 fix(runner): harden catalogue scrape against 429s; disable sourcemap upload
Some checks failed
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 43s
CI / Test backend (pull_request) Successful in 28s
Release / Docker / caddy (push) Successful in 1m8s
CI / Docker / caddy (pull_request) Failing after 39s
CI / Check ui (pull_request) Successful in 55s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 2m20s
CI / Docker / backend (pull_request) Successful in 2m32s
CI / Docker / ui (pull_request) Successful in 1m28s
CI / Docker / runner (pull_request) Successful in 2m25s
Release / Docker / backend (push) Successful in 2m33s
Release / Gitea Release (push) Failing after 2s
- scraper.go: ScrapeCatalogue now uses retryGet (9 attempts, 10s base) +
  500–1500ms inter-page jitter instead of bare GetContent. ScrapeMetadata
  also switched to retryGet so a single 429 on a book page is retried rather
  than aborting the whole refresh.
- catalogue_refresh.go: per-book delay is now configurable
  (RUNNER_CATALOGUE_REQUEST_DELAY, default 2s) + up to 50% random jitter
  applied before every metadata fetch. Only metadata is scraped here —
  chapters are fetched on-demand, not during catalogue refresh. Progress
  logged every 50 books instead of 100.
- config.go / runner.go / main.go: add CatalogueRequestDelay field wired
  from RUNNER_CATALOGUE_REQUEST_DELAY env var.
- release.yaml: comment out upload-sourcemaps job and remove it from the
  release needs; GlitchTip auth token needs refreshing after DB wipe.
2026-03-28 16:28:36 +05:00
Admin
58e78cd34d fix(infra): make MEILI_URL configurable; rename Discover → Catalogue in nav
Some checks failed
Release / Test backend (push) Failing after 11s
Release / Docker / caddy (push) Failing after 11s
Release / Check ui (push) Failing after 11s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Gitea Release (push) Has been skipped
CI / Check ui (pull_request) Successful in 49s
CI / Docker / ui (pull_request) Successful in 1m37s
CI / Docker / caddy (pull_request) Successful in 4m4s
CI / Test backend (pull_request) Successful in 4m29s
CI / Docker / runner (pull_request) Failing after 1m27s
CI / Docker / backend (pull_request) Successful in 1m41s
- docker-compose.yml: MEILI_URL in x-infra-env was hardcoded to the local
  meilisearch container, ignoring the Doppler MEILI_URL secret entirely.
  Changed to "${MEILI_URL:-http://meilisearch:7700}" so prod reads from
  https://search.libnovel.cc while local dev keeps the container default.
- ui/+layout.svelte: rename nav + footer label from "Discover" to "Catalogue"
  (desktop nav, mobile menu, footer — all three occurrences).
2026-03-28 16:15:04 +05:00
Admin
c5c167035d fix(tts): fix pocket-tts voices missing in UI and 500 on first TTS enqueue
Some checks failed
CI / Test backend (pull_request) Successful in 40s
CI / Check ui (pull_request) Successful in 45s
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 1m8s
CI / Docker / runner (pull_request) Failing after 35s
CI / Docker / caddy (pull_request) Successful in 3m39s
CI / Docker / ui (pull_request) Successful in 1m12s
CI / Docker / backend (pull_request) Successful in 3m24s
Release / Upload source maps (push) Failing after 43s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m51s
Release / Docker / backend (push) Failing after 7m14s
Release / Gitea Release (push) Has been skipped
- Add POCKET_TTS_URL env to backend service in docker-compose.yml so
  pocket-tts voices appear in the voice selector (Doppler secret existed
  but the env var was never passed to the container)
- Fix GetAudioTask PocketBase filter using %q (double-quotes) instead of
  single-quoted string, causing the duplicate-task guard to always miss
- Fix AudioPlayer double-POST: GET /api/presign/audio already enqueues
  TTS internally on 404; AudioPlayer now skips the redundant POST and
  polls directly, eliminating the 500 from the PB unique-key conflict
2026-03-28 16:02:46 +05:00
Admin
4a00d953bb feat(ui): show build version in footer; enable watchtower for caddy
Some checks failed
CI / Check ui (pull_request) Successful in 50s
CI / Docker / ui (pull_request) Failing after 38s
CI / Test backend (pull_request) Successful in 3m31s
CI / Docker / backend (pull_request) Failing after 11s
CI / Docker / runner (pull_request) Failing after 11s
CI / Docker / caddy (pull_request) Successful in 11m0s
- Footer now displays build version tag + short commit SHA (from
  PUBLIC_BUILD_VERSION / PUBLIC_BUILD_COMMIT env vars baked in at
  image build time); falls back to 'dev' in local builds
- docker-compose.yml: add watchtower label to caddy service so it
  auto-updates alongside backend/ui/runner on new image pushes
- homelab/docker-compose.yml: use locally-built kokoro-fastapi:latest
  image (consistent with actual homelab setup)
2026-03-28 15:42:22 +05:00
Admin
fe1a933fd0 feat(queue): replace PocketBase polling with Asynq + Redis
Some checks failed
CI / Docker / caddy (pull_request) Failing after 50s
CI / Check ui (pull_request) Successful in 1m5s
Release / Check ui (push) Successful in 45s
Release / Test backend (push) Successful in 1m29s
CI / Docker / ui (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 3m14s
CI / Test backend (pull_request) Failing after 42s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
Release / Docker / caddy (push) Successful in 6m48s
Release / Docker / ui (push) Successful in 2m8s
Release / Docker / runner (push) Successful in 2m51s
Release / Upload source maps (push) Failing after 53s
Release / Gitea Release (push) Has been skipped
Introduce a Redis-backed Asynq task queue so the runner consumes TTS
jobs pushed by the backend instead of polling PocketBase.

- backend/internal/asynqqueue: Producer and Consumer wrappers
- backend/internal/runner: AsynqRunner mux, per-instance Prometheus
  registry (fixes duplicate-collector panic in tests), redisConnOpt
- backend/internal/config: REDIS_ADDR / REDIS_PASSWORD env vars
- backend/cmd/{backend,runner}/main.go: wire Redis when env set; fall
  back to legacy poll mode when unset
- Caddyfile: caddy-l4 TCP proxy for redis.libnovel.cc:6380 → homelab
- caddy/Dockerfile: add --with github.com/mholt/caddy-l4
- docker-compose.yml: Caddy exposes 6380, backend/runner get Redis env
- homelab/runner/docker-compose.yml: Redis sidecar, runner depends_on
- homelab/otel/grafana: Grafana dashboards (backend, catalogue, runner)
  and alerting rules / contact-points provisioning
2026-03-28 14:32:40 +05:00
Admin
98e4a87432 feat(tts): dual-engine voice list (kokoro + pocket-tts)
Expose all available voices from both TTS engines via the /api/voices
endpoint. AudioPlayer and profile voice-selector now group voices by
engine and show a labelled optgroup. Voice type carries an engine field
so the chapter-reader can route synthesis to the correct backend.
2026-03-28 14:32:06 +05:00
Admin
9c8849c6cd fix(otel): accept full https:// URL in OTEL_EXPORTER_OTLP_ENDPOINT
Some checks failed
Release / Check ui (push) Successful in 39s
Release / Docker / caddy (push) Successful in 1m4s
Release / Test backend (push) Successful in 1m38s
Release / Docker / runner (push) Successful in 39s
Release / Docker / backend (push) Successful in 2m12s
Release / Docker / ui (push) Successful in 1m0s
Release / Upload source maps (push) Failing after 5m19s
Release / Gitea Release (push) Has been skipped
WithEndpoint expects host[:port] with no scheme. When Doppler has
https://otel.libnovel.cc the backend was crashing with 'invalid port'.
Now strip the scheme and enable TLS when prefix is https://.
2026-03-27 19:38:50 +05:00
Admin
b30aa23d64 fix(homelab): pocket-tts uses locally-built image, correct start command and volumes
Some checks failed
CI / Check ui (pull_request) Successful in 35s
CI / Test backend (pull_request) Successful in 39s
CI / Docker / ui (pull_request) Successful in 1m27s
CI / Docker / backend (pull_request) Successful in 1m57s
CI / Docker / runner (pull_request) Failing after 35s
CI / Docker / caddy (pull_request) Successful in 6m14s
- pocket-tts has no prebuilt image on ghcr.io — must be built from source on homelab
- Updated image to pocket-tts:latest (local tag), add command with --host 0.0.0.0
- Add model cache volumes (pocket_tts_cache, hf_cache) so model weights survive restarts
- start_period increased to 120s (first startup downloads weights)
2026-03-27 16:29:36 +05:00
123 changed files with 9614 additions and 2094 deletions

View File

@@ -2,20 +2,14 @@ name: CI
on: on:
push: push:
branches: ["main", "master"]
paths: paths:
- "backend/**" - "backend/**"
- "ui/**" - "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml" - ".gitea/workflows/ci.yaml"
pull_request: pull_request:
branches: ["main", "master"]
paths: paths:
- "backend/**" - "backend/**"
- "ui/**" - "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml" - ".gitea/workflows/ci.yaml"
concurrency: concurrency:
@@ -23,10 +17,13 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# ── backend: vet & test ─────────────────────────────────────────────────────── # ── Go: vet + build + test ────────────────────────────────────────────────
test-backend: backend:
name: Test backend name: Backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -36,16 +33,23 @@ jobs:
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: go vet - name: go vet
working-directory: backend
run: go vet ./... run: go vet ./...
- name: Build backend
run: go build -o /dev/null ./cmd/backend
- name: Build runner
run: go build -o /dev/null ./cmd/runner
- name: Build healthcheck
run: go build -o /dev/null ./cmd/healthcheck
- name: Run tests - name: Run tests
working-directory: backend
run: go test -short -race -count=1 -timeout=60s ./... run: go test -short -race -count=1 -timeout=60s ./...
# ── ui: type-check & build ──────────────────────────────────────────────────── # ── UI: type-check + build ────────────────────────────────────────────────
check-ui: ui:
name: Check ui name: UI
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -67,57 +71,3 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
docker-backend:
name: Docker / backend
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: backend
push: false
docker-runner:
name: Docker / runner
runs-on: ubuntu-latest
needs: [test-backend]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: backend
target: runner
push: false
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: ui
push: false
docker-caddy:
name: Docker / caddy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build
uses: docker/build-push-action@v6
with:
context: caddy
push: false

View File

@@ -136,52 +136,51 @@ jobs:
cache-to: type=inline cache-to: type=inline
# ── ui: source map upload ───────────────────────────────────────────────────── # ── ui: source map upload ─────────────────────────────────────────────────────
# Builds the UI with source maps and uploads them to GlitchTip so that error # Commented out: GlitchTip project/auth token needs to be recreated after
# stack traces resolve to original .svelte/.ts file names and line numbers. # the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
# Runs in parallel with docker-ui (both need check-ui to pass first). # upload-sourcemaps:
upload-sourcemaps: # name: Upload source maps
name: Upload source maps # runs-on: ubuntu-latest
runs-on: ubuntu-latest # needs: [check-ui]
needs: [check-ui] # defaults:
defaults: # run:
run: # working-directory: ui
working-directory: ui # steps:
steps: # - uses: actions/checkout@v4
- uses: actions/checkout@v4 #
# - uses: actions/setup-node@v4
- uses: actions/setup-node@v4 # with:
with: # node-version: "22"
node-version: "22" # cache: npm
cache: npm # cache-dependency-path: ui/package-lock.json
cache-dependency-path: ui/package-lock.json #
# - name: Install dependencies
- name: Install dependencies # run: npm ci
run: npm ci #
# - name: Build with source maps
- name: Build with source maps # run: npm run build
run: npm run build #
# - name: Download glitchtip-cli
- name: Download glitchtip-cli # run: |
run: | # curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \ # -o /usr/local/bin/glitchtip-cli
-o /usr/local/bin/glitchtip-cli # chmod +x /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli #
# - name: Inject debug IDs into build artifacts
- name: Inject debug IDs into build artifacts # run: glitchtip-cli sourcemaps inject ./build
run: glitchtip-cli sourcemaps inject ./build # env:
env: # SENTRY_URL: https://errors.libnovel.cc/
SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel
SENTRY_ORG: libnovel # SENTRY_PROJECT: libnovel-ui
SENTRY_PROJECT: libnovel-ui #
# - name: Upload source maps to GlitchTip
- name: Upload source maps to GlitchTip # run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }} # env:
env: # SENTRY_URL: https://errors.libnovel.cc/
SENTRY_URL: https://errors.libnovel.cc/ # SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} # SENTRY_ORG: libnovel
SENTRY_ORG: libnovel # SENTRY_PROJECT: libnovel-ui
SENTRY_PROJECT: libnovel-ui
# ── docker: ui ──────────────────────────────────────────────────────────────── # ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui: docker-ui:
@@ -191,6 +190,17 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Fetch releases from Gitea API
run: |
set -euo pipefail
RESPONSE=$(curl -sfL \
-H "Accept: application/json" \
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
# Validate JSON before writing — fails hard if response is not a JSON array
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
echo "$RESPONSE" > ui/static/releases.json
echo "Fetched $COUNT releases"
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
@@ -219,6 +229,7 @@ jobs:
build-args: | build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }} BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }} BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline cache-to: type=inline
@@ -261,14 +272,14 @@ jobs:
release: release:
name: Gitea Release name: Gitea Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps] needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Create release - name: Create release
uses: actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
with: with:
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
generate_release_notes: true generate_release_notes: true

View File

@@ -56,6 +56,22 @@
ticker_interval 15s ticker_interval 15s
} }
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
# Exposes homelab Redis over TLS for Asynq job enqueueing from the backend.
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
# for redis.libnovel.cc; traffic is proxied to the homelab Redis instance.
# Requires the caddy-l4 module in the custom Caddy build.
layer4 {
:6380 {
route {
tls
proxy {
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
}
}
}
}
}
(security_headers) { (security_headers) {
header { header {
@@ -170,12 +186,31 @@
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ─────── # ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
handle { handle {
reverse_proxy ui:3000 { reverse_proxy ui:3000 {
} # Active health check: Caddy polls /health every 5 s and marks the
# upstream down immediately when it fails. Combined with
# lb_try_duration this means Watchtower container replacements
# show the maintenance page within a few seconds instead of
# hanging or returning a raw connection error to the browser.
health_uri /health
health_interval 5s
health_timeout 2s
health_status 200
# If the upstream is down, fail fast (don't retry for longer than
# 3 s) and let Caddy's handle_errors 502/503 take over.
lb_try_duration 3s
}
}
# ── Caddy-level error pages ─────────────────────────────────────────────── # ── Caddy-level error pages ───────────────────────────────────────────────
# These fire when the upstream (backend or ui) is completely unreachable. # These fire when the upstream (backend or ui) is completely unreachable.
# SvelteKit's own +error.svelte handles application-level errors (404, 500). # SvelteKit's own +error.svelte handles application-level errors (404, 500).
handle_errors 404 { handle_errors 404 {
root * /srv/errors
rewrite * /404.html
file_server
}
handle_errors 502 {
root * /srv/errors root * /srv/errors
rewrite * /502.html rewrite * /502.html
file_server file_server

View File

@@ -30,9 +30,14 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
-o /out/healthcheck ./cmd/healthcheck -o /out/healthcheck ./cmd/healthcheck
# ── backend service ────────────────────────────────────────────────────────── # ── backend service ──────────────────────────────────────────────────────────
FROM gcr.io/distroless/static:nonroot AS backend # Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
# sample generation via pocket-tts (WAV→MP3 transcoding).
FROM alpine:3.21 AS backend
RUN apk add --no-cache ffmpeg ca-certificates && \
addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /out/healthcheck /healthcheck COPY --from=builder /out/healthcheck /healthcheck
COPY --from=builder /out/backend /backend COPY --from=builder /out/backend /backend
USER appuser
ENTRYPOINT ["/backend"] ENTRYPOINT ["/backend"]
# ── runner service ─────────────────────────────────────────────────────────── # ── runner service ───────────────────────────────────────────────────────────

Binary file not shown.

View File

@@ -22,12 +22,16 @@ import (
"time" "time"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/backend" "github.com/libnovel/backend/internal/backend"
"github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/otelsetup" "github.com/libnovel/backend/internal/otelsetup"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/storage" "github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
) )
// version and commit are set at build time via -ldflags. // version and commit are set at build time via -ldflags.
@@ -100,6 +104,15 @@ func run() error {
kokoroClient = &noopKokoro{} kokoroClient = &noopKokoro{}
} }
// ── Pocket-TTS (voice list + sample generation; audio generation is the runner's job) ──
var pocketTTSClient pockettts.Client
if cfg.PocketTTS.URL != "" {
pocketTTSClient = pockettts.New(cfg.PocketTTS.URL)
log.Info("pocket-tts voices enabled", "url", cfg.PocketTTS.URL)
} else {
log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
}
// ── Meilisearch (search reads only; indexing is the runner's job) ──────── // ── Meilisearch (search reads only; indexing is the runner's job) ────────
var searchIndex meili.Client var searchIndex meili.Client
if cfg.Meilisearch.URL != "" { if cfg.Meilisearch.URL != "" {
@@ -110,6 +123,24 @@ func run() error {
searchIndex = meili.NoopClient{} searchIndex = meili.NoopClient{}
} }
// ── Task Producer ────────────────────────────────────────────────────────
// When REDIS_ADDR is set the backend dual-writes: PocketBase record (audit)
// + Asynq job (immediate delivery). Otherwise it writes to PocketBase only
// and the runner picks up on the next poll tick.
var producer taskqueue.Producer = store
if cfg.Redis.Addr != "" {
redisOpt, parseErr := parseRedisOpt(cfg.Redis)
if parseErr != nil {
return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
}
asynqProducer := asynqqueue.NewProducer(store, redisOpt)
defer asynqProducer.Close() //nolint:errcheck
producer = asynqProducer
log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
} else {
log.Info("backend: poll-mode task dispatch (REDIS_ADDR not set)")
}
// ── Backend server ─────────────────────────────────────────────────────── // ── Backend server ───────────────────────────────────────────────────────
srv := backend.New( srv := backend.New(
backend.Config{ backend.Config{
@@ -119,17 +150,19 @@ func run() error {
Commit: commit, Commit: commit,
}, },
backend.Dependencies{ backend.Dependencies{
BookReader: store, BookReader: store,
RankingStore: store, RankingStore: store,
AudioStore: store, AudioStore: store,
PresignStore: store, TranslationStore: store,
ProgressStore: store, PresignStore: store,
CoverStore: store, ProgressStore: store,
Producer: store, CoverStore: store,
TaskReader: store, Producer: producer,
SearchIndex: searchIndex, TaskReader: store,
Kokoro: kokoroClient, SearchIndex: searchIndex,
Log: log, Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
Log: log,
}, },
) )
@@ -165,3 +198,16 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) { func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil return nil, nil
} }
// parseRedisOpt converts a config.Redis into an asynq.RedisConnOpt.
// Handles full "redis://" / "rediss://" URLs and plain "host:port".
func parseRedisOpt(cfg config.Redis) (asynq.RedisConnOpt, error) {
addr := cfg.Addr
if len(addr) > 7 && (addr[:8] == "redis://" || (len(addr) > 8 && addr[:9] == "rediss://")) {
return asynq.ParseRedisURI(addr)
}
return asynq.RedisClientOpt{
Addr: addr,
Password: cfg.Password,
}, nil
}

View File

@@ -20,15 +20,18 @@ import (
"time" "time"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/browser" "github.com/libnovel/backend/internal/browser"
"github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/novelfire" "github.com/libnovel/backend/internal/novelfire"
"github.com/libnovel/backend/internal/otelsetup" "github.com/libnovel/backend/internal/otelsetup"
"github.com/libnovel/backend/internal/pockettts" "github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/runner" "github.com/libnovel/backend/internal/runner"
"github.com/libnovel/backend/internal/storage" "github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/taskqueue"
) )
// version and commit are set at build time via -ldflags. // version and commit are set at build time via -ldflags.
@@ -126,6 +129,14 @@ func run() error {
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail") log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
} }
// ── LibreTranslate ──────────────────────────────────────────────────────
ltClient := libretranslate.New(cfg.LibreTranslate.URL, cfg.LibreTranslate.APIKey)
if ltClient != nil {
log.Info("libretranslate enabled", "url", cfg.LibreTranslate.URL)
} else {
log.Info("LIBRETRANSLATE_URL not set — machine translation disabled")
}
// ── Meilisearch ───────────────────────────────────────────────────────── // ── Meilisearch ─────────────────────────────────────────────────────────
var searchIndex meili.Client var searchIndex meili.Client
if cfg.Meilisearch.URL != "" { if cfg.Meilisearch.URL != "" {
@@ -147,22 +158,40 @@ func run() error {
PollInterval: cfg.Runner.PollInterval, PollInterval: cfg.Runner.PollInterval,
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
MaxConcurrentTranslation: cfg.Runner.MaxConcurrentTranslation,
OrchestratorWorkers: workers, OrchestratorWorkers: workers,
MetricsAddr: cfg.Runner.MetricsAddr, MetricsAddr: cfg.Runner.MetricsAddr,
CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval, CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval,
CatalogueRequestDelay: cfg.Runner.CatalogueRequestDelay,
SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh, SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh,
RedisAddr: cfg.Redis.Addr,
RedisPassword: cfg.Redis.Password,
} }
// In Asynq mode the Consumer is a thin wrapper: claim/heartbeat/reap are
// no-ops, but FinishAudioTask / FinishScrapeTask / FailTask write back to
// PocketBase as before.
var consumer taskqueue.Consumer = store
if cfg.Redis.Addr != "" {
log.Info("runner: asynq mode — using Redis for task dispatch", "addr", cfg.Redis.Addr)
consumer = asynqqueue.NewConsumer(store)
} else {
log.Info("runner: poll mode — using PocketBase for task dispatch")
}
deps := runner.Dependencies{ deps := runner.Dependencies{
Consumer: store, Consumer: consumer,
BookWriter: store, BookWriter: store,
BookReader: store, BookReader: store,
AudioStore: store, AudioStore: store,
CoverStore: store, CoverStore: store,
SearchIndex: searchIndex, TranslationStore: store,
Novel: novel, SearchIndex: searchIndex,
Kokoro: kokoroClient, Novel: novel,
PocketTTS: pocketTTSClient, Kokoro: kokoroClient,
Log: log, PocketTTS: pocketTTSClient,
LibreTranslate: ltClient,
Log: log,
} }
r := runner.New(rCfg, deps) r := runner.New(rCfg, deps)

View File

@@ -9,6 +9,7 @@ require (
require ( require (
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
@@ -22,17 +23,27 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hibiken/asynq v0.26.0 // indirect
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/meilisearch/meilisearch-go v0.36.1 // indirect github.com/meilisearch/meilisearch-go v0.36.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
@@ -47,10 +58,12 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.2 // indirect google.golang.org/grpc v1.79.2 // indirect

View File

@@ -1,5 +1,7 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -27,6 +29,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d/go.mod h1:hhpStehaxSGg3ib9wJXzw5AXY1YS6lQ9BNavAgPbIhE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -42,20 +48,44 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k= go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
@@ -84,6 +114,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -94,6 +126,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=

View File

@@ -0,0 +1,64 @@
package asynqqueue
import (
"context"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
//
// When using Asynq, the runner no longer polls for work — Asynq delivers
// tasks via the ServeMux handlers. The only Consumer operations the handlers
// need are:
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
// - FailTask — mark PocketBase record as failed
//
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
// are all no-ops here because Asynq owns those responsibilities.
type Consumer struct {
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
}
// NewConsumer wraps an existing PocketBase Consumer.
func NewConsumer(pb taskqueue.Consumer) *Consumer {
return &Consumer{pb: pb}
}
// ── Write-back (delegated to PocketBase) ──────────────────────────────────────
func (c *Consumer) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
return c.pb.FinishScrapeTask(ctx, id, result)
}
func (c *Consumer) FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error {
return c.pb.FinishAudioTask(ctx, id, result)
}
func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error {
return c.pb.FinishTranslationTask(ctx, id, result)
}
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
return c.pb.FailTask(ctx, id, errMsg)
}
// ── No-ops (Asynq owns claiming / heartbeating / reaping) ───────────────────
func (c *Consumer) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
return domain.ScrapeTask{}, false, nil
}
func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
return domain.AudioTask{}, false, nil
}
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }

View File

@@ -0,0 +1,96 @@
package asynqqueue
import (
"context"
"encoding/json"
"fmt"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/taskqueue"
)
// Producer dual-writes every task: first to PocketBase (via pb, for audit /
// UI status), then to Redis via Asynq so the runner picks it up immediately.
type Producer struct {
pb taskqueue.Producer // underlying PocketBase producer
client *asynq.Client
}
// NewProducer wraps an existing PocketBase Producer with Asynq dispatch.
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt) *Producer {
return &Producer{
pb: pb,
client: asynq.NewClient(redisOpt),
}
}
// Close shuts down the underlying Asynq client connection.
func (p *Producer) Close() error {
return p.client.Close()
}
// CreateScrapeTask creates a PocketBase record then enqueues an Asynq job.
func (p *Producer) CreateScrapeTask(ctx context.Context, kind, targetURL string, fromChapter, toChapter int) (string, error) {
id, err := p.pb.CreateScrapeTask(ctx, kind, targetURL, fromChapter, toChapter)
if err != nil {
return "", err
}
payload := ScrapePayload{
PBTaskID: id,
Kind: kind,
TargetURL: targetURL,
FromChapter: fromChapter,
ToChapter: toChapter,
}
taskType := TypeScrapeBook
if kind == "catalogue" {
taskType = TypeScrapeCatalogue
}
if err := p.enqueue(ctx, taskType, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.
return id, fmt.Errorf("asynq enqueue scrape (task still in PB): %w", err)
}
return id, nil
}
// CreateAudioTask creates a PocketBase record then enqueues an Asynq job.
func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) {
id, err := p.pb.CreateAudioTask(ctx, slug, chapter, voice)
if err != nil {
return "", err
}
payload := AudioPayload{
PBTaskID: id,
Slug: slug,
Chapter: chapter,
Voice: voice,
}
if err := p.enqueue(ctx, TypeAudioGenerate, payload); err != nil {
return id, fmt.Errorf("asynq enqueue audio (task still in PB): %w", err)
}
return id, nil
}
// CreateTranslationTask creates a PocketBase record. Translation tasks are
// not currently dispatched via Asynq — the runner picks them up via polling.
func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error) {
return p.pb.CreateTranslationTask(ctx, slug, chapter, lang)
}
// CancelTask delegates to PocketBase; Asynq jobs may already be running and
// cannot be reliably cancelled, so we only update the audit record.
func (p *Producer) CancelTask(ctx context.Context, id string) error {
return p.pb.CancelTask(ctx, id)
}
// enqueue serialises payload and dispatches it to Asynq.
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
b, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
_, err = p.client.Enqueue(asynq.NewTask(taskType, b))
return err
}

View File

@@ -0,0 +1,46 @@
// Package asynqqueue provides Asynq-backed implementations of the
// taskqueue.Producer and taskqueue.Consumer interfaces.
//
// Architecture:
// - Producer: dual-writes — creates a PocketBase record for audit/UI, then
// enqueues an Asynq job so the runner picks it up immediately (sub-ms).
// - Consumer: thin wrapper used only for result write-back (FinishAudioTask,
// FinishScrapeTask, FailTask). ClaimNext*/Heartbeat/Reap are no-ops because
// Asynq owns those responsibilities.
// - Handlers: asynq.HandlerFunc wrappers that decode job payloads and invoke
// the existing runner logic (runScrapeTask / runAudioTask).
//
// Fallback: when REDIS_ADDR is empty the caller should use the plain
// storage.Store (PocketBase-polling) implementation unchanged.
package asynqqueue
// Queue names — keep all jobs on the default queue for now.
// Add separate queues (e.g. "audio", "scrape") later if you need priority.
const QueueDefault = "default"
// Task type constants used for Asynq routing.
const (
TypeAudioGenerate = "audio:generate"
TypeScrapeBook = "scrape:book"
TypeScrapeCatalogue = "scrape:catalogue"
)
// AudioPayload is the Asynq job payload for audio generation tasks.
type AudioPayload struct {
// PBTaskID is the PocketBase record ID created before enqueueing.
// The handler uses it to write results back via Consumer.FinishAudioTask.
PBTaskID string `json:"pb_task_id"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
}
// ScrapePayload is the Asynq job payload for scrape tasks.
type ScrapePayload struct {
// PBTaskID is the PocketBase record ID created before enqueueing.
PBTaskID string `json:"pb_task_id"`
Kind string `json:"kind"` // "catalogue", "book", or "book_range"
TargetURL string `json:"target_url"` // empty for catalogue tasks
FromChapter int `json:"from_chapter"` // 0 unless Kind=="book_range"
ToChapter int `json:"to_chapter"` // 0 unless Kind=="book_range"
}

View File

@@ -32,6 +32,7 @@ package backend
// directly (no runner task, no store writes). Used for unscraped books. // directly (no runner task, no store writes). Used for unscraped books.
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -47,7 +48,9 @@ import (
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/novelfire/htmlutil" "github.com/libnovel/backend/internal/novelfire/htmlutil"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper" "github.com/libnovel/backend/internal/scraper"
"github.com/yuin/goldmark"
) )
const ( const (
@@ -700,10 +703,253 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, presignURL, http.StatusFound) http.Redirect(w, r, presignURL, http.StatusFound)
} }
// ── Voices ───────────────────────────────────────────────────────────────────── // ── Translation ────────────────────────────────────────────────────────────────
// handleVoices handles GET /api/voices. // supportedTranslationLangs is the set of target locales the backend accepts.
// Returns {"voices": [...]} — fetched from Kokoro with built-in fallback. // Source is always "en".
var supportedTranslationLangs = map[string]bool{
"ru": true, "id": true, "pt": true, "fr": true,
}
// handleTranslationGenerate handles POST /api/translation/{slug}/{n}.
// Query params: lang (required, one of ru|id|pt|fr)
//
// Returns 200 immediately if translation already exists in MinIO.
// Returns 202 with task_id if a new task was created.
// Returns 503 if TranslationStore is nil (feature disabled).
func (s *Server) handleTranslationGenerate(w http.ResponseWriter, r *http.Request) {
if s.deps.TranslationStore == nil {
jsonError(w, http.StatusServiceUnavailable, "machine translation not configured")
return
}
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
jsonError(w, http.StatusBadRequest, "invalid chapter")
return
}
lang := r.URL.Query().Get("lang")
if !supportedTranslationLangs[lang] {
jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
return
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)
// Fast path: translation already in MinIO
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
return
}
// Check if a task is already pending/running
task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
writeJSON(w, http.StatusAccepted, map[string]string{
"task_id": task.ID,
"status": string(task.Status),
"lang": lang,
})
return
}
// Create a new translation task
taskID, err := s.deps.Producer.CreateTranslationTask(r.Context(), slug, n, lang)
if err != nil {
s.deps.Log.Error("handleTranslationGenerate: CreateTranslationTask failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to create translation task")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"task_id": taskID,
"status": "pending",
"lang": lang,
})
}
// handleTranslationStatus handles GET /api/translation/status/{slug}/{n}.
// Query params: lang (required)
func (s *Server) handleTranslationStatus(w http.ResponseWriter, r *http.Request) {
if s.deps.TranslationStore == nil {
writeJSON(w, 0, map[string]string{"status": "unavailable"})
return
}
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid params")
return
}
lang := r.URL.Query().Get("lang")
if !supportedTranslationLangs[lang] {
jsonError(w, http.StatusBadRequest, "unsupported lang")
return
}
// Fast path: translation exists in MinIO
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
return
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)
task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
if !found {
writeJSON(w, 0, map[string]string{"status": "idle", "lang": lang})
return
}
resp := map[string]string{
"status": string(task.Status),
"task_id": task.ID,
"lang": lang,
}
if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
resp["error"] = task.ErrorMessage
}
writeJSON(w, 0, resp)
}
// handleTranslationRead handles GET /api/translation/{slug}/{n}.
// Query params: lang (required)
//
// Returns {"html": "<p>...</p>", "lang": "ru"} from the MinIO-cached translation.
// Returns 404 when the translation has not been generated yet.
func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
if s.deps.TranslationStore == nil {
http.Error(w, `{"error":"machine translation not configured"}`, http.StatusServiceUnavailable)
return
}
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
jsonError(w, http.StatusBadRequest, "invalid params")
return
}
lang := r.URL.Query().Get("lang")
if !supportedTranslationLangs[lang] {
jsonError(w, http.StatusBadRequest, "unsupported lang")
return
}
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
md, err := s.deps.TranslationStore.GetTranslation(r.Context(), key)
if err != nil {
s.deps.Log.Warn("handleTranslationRead: translation not found", "slug", slug, "n", n, "lang", lang, "err", err)
jsonError(w, http.StatusNotFound, "translation not available")
return
}
var buf bytes.Buffer
if err := goldmark.Convert([]byte(md), &buf); err != nil {
s.deps.Log.Error("handleTranslationRead: markdown conversion failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to render translation")
return
}
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
}
// handleAdminTranslationJobs handles GET /api/admin/translation/jobs.
// Returns the full list of translation jobs sorted by started descending.
func (s *Server) handleAdminTranslationJobs(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListTranslationTasks(r.Context())
if err != nil {
s.deps.Log.Error("handleAdminTranslationJobs: ListTranslationTasks failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list translation jobs")
return
}
type jobRow struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Lang string `json:"lang"`
Status string `json:"status"`
WorkerID string `json:"worker_id"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
rows := make([]jobRow, 0, len(tasks))
for _, t := range tasks {
rows = append(rows, jobRow{
ID: t.ID,
CacheKey: t.CacheKey,
Slug: t.Slug,
Chapter: t.Chapter,
Lang: t.Lang,
Status: string(t.Status),
WorkerID: t.WorkerID,
ErrorMessage: t.ErrorMessage,
Started: t.Started.Format(time.RFC3339),
Finished: t.Finished.Format(time.RFC3339),
})
}
writeJSON(w, 0, map[string]any{"jobs": rows})
}
// handleAdminTranslationBulk handles POST /api/admin/translation/bulk.
// Body: {"slug": "...", "lang": "ru", "from": 1, "to": 50}
// Enqueues one translation task per chapter in the range [from, to] inclusive.
func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
Lang string `json:"lang"`
From int `json:"from"`
To int `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if !supportedTranslationLangs[body.Lang] {
jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
return
}
if body.From < 1 || body.To < body.From {
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
return
}
if body.To-body.From > 999 {
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
return
}
var taskIDs []string
for n := body.From; n <= body.To; n++ {
id, err := s.deps.Producer.CreateTranslationTask(r.Context(), body.Slug, n, body.Lang)
if err != nil {
s.deps.Log.Error("handleAdminTranslationBulk: CreateTranslationTask failed",
"slug", body.Slug, "chapter", n, "lang", body.Lang, "err", err)
jsonError(w, http.StatusInternalServerError,
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
return
}
taskIDs = append(taskIDs, id)
}
writeJSON(w, http.StatusAccepted, map[string]any{
"enqueued": len(taskIDs),
"task_ids": taskIDs,
})
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) { func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())}) writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
} }
@@ -763,8 +1009,8 @@ const voiceSampleText = "Hello! This is a preview of what I sound like. I hope y
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}. // handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
// If the sample has not been generated yet it synthesises it on the fly via // If the sample has not been generated yet it synthesises it on the fly via
// Kokoro, stores the result in MinIO, and returns the presigned URL — so the // the appropriate TTS engine (Kokoro for kokoro voices, pocket-tts for
// caller always gets a playable URL in a single request. // pocket-tts voices), stores the result in MinIO, and returns the presigned URL.
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
voice := r.PathValue("voice") voice := r.PathValue("voice")
if voice == "" { if voice == "" {
@@ -777,7 +1023,20 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
// Generate sample on demand when it is not in MinIO yet. // Generate sample on demand when it is not in MinIO yet.
if !s.deps.AudioStore.AudioExists(r.Context(), key) { if !s.deps.AudioStore.AudioExists(r.Context(), key) {
s.deps.Log.Info("generating voice sample on demand", "voice", voice) s.deps.Log.Info("generating voice sample on demand", "voice", voice)
mp3, err := s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
var (
mp3 []byte
err error
)
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
mp3, err = s.deps.PocketTTS.GenerateAudio(r.Context(), voiceSampleText, voice)
} else {
mp3, err = s.deps.Kokoro.GenerateAudio(r.Context(), voiceSampleText, voice)
}
if err != nil { if err != nil {
s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err) s.deps.Log.Error("voice sample generation failed", "voice", voice, "err", err)
jsonError(w, http.StatusInternalServerError, "voice sample generation failed") jsonError(w, http.StatusInternalServerError, "voice sample generation failed")
@@ -1148,9 +1407,9 @@ func stripMarkdown(src string) string {
// ── Hardcoded Kokoro voice fallback ─────────────────────────────────────────── // ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoices is the built-in fallback list used when the Kokoro service is // kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
// unavailable. Matches the list in the old scraper helpers.go. // the Kokoro service is unavailable.
var kokoroVoices = []string{ var kokoroVoiceIDs = []string{
// American English // American English
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia", "af_alloy", "af_aoede", "af_bella", "af_heart", "af_jadzia",
"af_jessica", "af_kore", "af_nicole", "af_nova", "af_river", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",

View File

@@ -30,8 +30,10 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/libnovel/backend/internal/bookstore" "github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/taskqueue" "github.com/libnovel/backend/internal/taskqueue"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -45,6 +47,8 @@ type Dependencies struct {
RankingStore bookstore.RankingStore RankingStore bookstore.RankingStore
// AudioStore checks audio object existence and computes MinIO keys. // AudioStore checks audio object existence and computes MinIO keys.
AudioStore bookstore.AudioStore AudioStore bookstore.AudioStore
// TranslationStore checks translation existence and reads/writes translated markdown.
TranslationStore bookstore.TranslationStore
// PresignStore generates short-lived MinIO URLs. // PresignStore generates short-lived MinIO URLs.
PresignStore bookstore.PresignStore PresignStore bookstore.PresignStore
// ProgressStore reads/writes per-session reading progress. // ProgressStore reads/writes per-session reading progress.
@@ -59,9 +63,12 @@ type Dependencies struct {
// SearchIndex provides full-text book search via Meilisearch. // SearchIndex provides full-text book search via Meilisearch.
// If nil, the local-only fallback search is used. // If nil, the local-only fallback search is used.
SearchIndex meili.Client SearchIndex meili.Client
// Kokoro is the TTS client (used for voice list only in the backend; // Kokoro is the Kokoro TTS client (used for voice list only in the backend;
// audio generation is done by the runner). // audio generation is done by the runner).
Kokoro kokoro.Client Kokoro kokoro.Client
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
// audio generation is done by the runner).
PocketTTS pockettts.Client
// Log is the structured logger. // Log is the structured logger.
Log *slog.Logger Log *slog.Logger
} }
@@ -84,7 +91,7 @@ type Server struct {
// voiceMu guards cachedVoices. Populated lazily on first GET /api/voices. // voiceMu guards cachedVoices. Populated lazily on first GET /api/voices.
voiceMu sync.RWMutex voiceMu sync.RWMutex
cachedVoices []string cachedVoices []domain.Voice
} }
// New creates a Server from cfg and deps. // New creates a Server from cfg and deps.
@@ -155,6 +162,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus) mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy) mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
// Admin translation endpoints
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Voices list // Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices) mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -264,10 +280,10 @@ func jsonError(w http.ResponseWriter, status int, msg string) {
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
} }
// voices returns the list of available Kokoro voices. On the first call it // voices returns the merged list of available voices from Kokoro and pocket-tts.
// fetches from the Kokoro service and caches the result. Falls back to the // On the first call it fetches from both services and caches the result.
// hardcoded list on error. // Falls back to the hardcoded Kokoro list on error.
func (s *Server) voices(ctx context.Context) []string { func (s *Server) voices(ctx context.Context) []domain.Voice {
s.voiceMu.RLock() s.voiceMu.RLock()
cached := s.cachedVoices cached := s.cachedVoices
s.voiceMu.RUnlock() s.voiceMu.RUnlock()
@@ -275,23 +291,89 @@ func (s *Server) voices(ctx context.Context) []string {
return cached return cached
} }
if s.deps.Kokoro == nil {
return kokoroVoices
}
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
list, err := s.deps.Kokoro.ListVoices(fetchCtx)
if err != nil || len(list) == 0 { var result []domain.Voice
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
return kokoroVoices // ── Kokoro voices ─────────────────────────────────────────────────────────
var kokoroIDs []string
if s.deps.Kokoro != nil {
ids, err := s.deps.Kokoro.ListVoices(fetchCtx)
if err != nil || len(ids) == 0 {
s.deps.Log.Warn("backend: could not fetch kokoro voices, using built-in list", "err", err)
ids = kokoroVoiceIDs
} else {
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(ids))
}
kokoroIDs = ids
} else {
kokoroIDs = kokoroVoiceIDs
}
for _, id := range kokoroIDs {
result = append(result, kokoroVoice(id))
}
// ── Pocket-TTS voices ─────────────────────────────────────────────────────
if s.deps.PocketTTS != nil {
ids, err := s.deps.PocketTTS.ListVoices(fetchCtx)
if err != nil {
s.deps.Log.Warn("backend: could not fetch pocket-tts voices", "err", err)
} else {
for _, id := range ids {
result = append(result, pocketTTSVoice(id))
}
s.deps.Log.Info("backend: fetched pocket-tts voices", "count", len(ids))
}
} }
s.voiceMu.Lock() s.voiceMu.Lock()
s.cachedVoices = list s.cachedVoices = result
s.voiceMu.Unlock() s.voiceMu.Unlock()
s.deps.Log.Info("backend: fetched kokoro voices", "count", len(list)) return result
return list }
// kokoroVoice builds a domain.Voice for a Kokoro voice ID.
// The two-character prefix encodes language and gender:
//
// af/am → en-us f/m | bf/bm → en-gb f/m
// ef/em → es f/m | ff → fr f
// hf/hm → hi f/m | if/im → it f/m
// jf/jm → ja f/m | pf/pm → pt f/m
// zf/zm → zh f/m
func kokoroVoice(id string) domain.Voice {
type meta struct{ lang, gender string }
prefixMap := map[string]meta{
"af": {"en-us", "f"}, "am": {"en-us", "m"},
"bf": {"en-gb", "f"}, "bm": {"en-gb", "m"},
"ef": {"es", "f"}, "em": {"es", "m"},
"ff": {"fr", "f"},
"hf": {"hi", "f"}, "hm": {"hi", "m"},
"if": {"it", "f"}, "im": {"it", "m"},
"jf": {"ja", "f"}, "jm": {"ja", "m"},
"pf": {"pt", "f"}, "pm": {"pt", "m"},
"zf": {"zh", "f"}, "zm": {"zh", "m"},
}
if len(id) >= 2 {
if m, ok := prefixMap[id[:2]]; ok {
return domain.Voice{ID: id, Engine: "kokoro", Lang: m.lang, Gender: m.gender}
}
}
return domain.Voice{ID: id, Engine: "kokoro", Lang: "en", Gender: ""}
}
// pocketTTSVoice builds a domain.Voice for a pocket-tts voice ID.
// All pocket-tts voices are English audiobook narrators.
func pocketTTSVoice(id string) domain.Voice {
femaleVoices := map[string]struct{}{
"alba": {}, "fantine": {}, "cosette": {}, "eponine": {},
"azelma": {}, "anna": {}, "vera": {}, "mary": {}, "jane": {}, "eve": {},
}
gender := "m"
if _, ok := femaleVoices[id]; ok {
gender = "f"
}
return domain.Voice{ID: id, Engine: "pocket-tts", Lang: "en", Gender: gender}
} }
// handleHealth handles GET /health. // handleHealth handles GET /health.

View File

@@ -141,3 +141,19 @@ type CoverStore interface {
// CoverExists returns true when a cover image is stored for slug. // CoverExists returns true when a cover image is stored for slug.
CoverExists(ctx context.Context, slug string) bool CoverExists(ctx context.Context, slug string) bool
} }
// TranslationStore covers machine-translated chapter storage in MinIO.
// The runner writes translations; the backend reads them.
type TranslationStore interface {
// TranslationObjectKey returns the MinIO object key for a cached translation.
TranslationObjectKey(lang, slug string, n int) string
// TranslationExists returns true when the translation object is present in MinIO.
TranslationExists(ctx context.Context, key string) bool
// PutTranslation stores raw translated markdown under the given MinIO object key.
PutTranslation(ctx context.Context, key string, data []byte) error
// GetTranslation retrieves translated markdown from MinIO.
GetTranslation(ctx context.Context, key string) (string, error)
}

View File

@@ -46,6 +46,8 @@ type MinIO struct {
BucketAvatars string BucketAvatars string
// BucketBrowse is the bucket that holds cached browse page snapshots (JSON). // BucketBrowse is the bucket that holds cached browse page snapshots (JSON).
BucketBrowse string BucketBrowse string
// BucketTranslations is the bucket that holds machine-translated chapter markdown.
BucketTranslations string
} }
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service. // Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
@@ -64,6 +66,16 @@ type PocketTTS struct {
URL string URL string
} }
// LibreTranslate holds connection settings for a self-hosted LibreTranslate instance.
type LibreTranslate struct {
// URL is the base URL of the LibreTranslate instance, e.g. https://translate.libnovel.cc
// An empty string disables machine translation entirely.
URL string
// APIKey is the optional API key for the LibreTranslate instance.
// Leave empty if the instance runs without authentication.
APIKey string
}
// HTTP holds settings for the HTTP server (backend only). // HTTP holds settings for the HTTP server (backend only).
type HTTP struct { type HTTP struct {
// Addr is the listen address, e.g. ":8080" // Addr is the listen address, e.g. ":8080"
@@ -86,6 +98,19 @@ type Valkey struct {
Addr string Addr string
} }
// Redis holds connection settings for the Asynq task queue Redis instance.
// This is separate from Valkey (presign cache) — it may point to the same
// Redis or a dedicated one. An empty Addr falls back to PocketBase polling.
type Redis struct {
// Addr is the host:port (or rediss://... URL) of the Redis instance.
// Use rediss:// scheme for TLS (e.g. rediss://:password@redis.libnovel.cc:6380).
// An empty string disables Asynq and falls back to PocketBase polling.
Addr string
// Password is the Redis AUTH password.
// Not needed when Addr is a full rediss:// URL that includes the password.
Password string
}
// Runner holds settings specific to the runner/worker binary. // Runner holds settings specific to the runner/worker binary.
type Runner struct { type Runner struct {
// PollInterval is how often the runner checks PocketBase for pending tasks. // PollInterval is how often the runner checks PocketBase for pending tasks.
@@ -94,6 +119,8 @@ type Runner struct {
MaxConcurrentScrape int MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines. // MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int MaxConcurrentAudio int
// MaxConcurrentTranslation limits simultaneous translation goroutines.
MaxConcurrentTranslation int
// WorkerID is a unique identifier for this runner instance. // WorkerID is a unique identifier for this runner instance.
// Defaults to the system hostname. // Defaults to the system hostname.
WorkerID string WorkerID string
@@ -113,18 +140,25 @@ type Runner struct {
// is already indexed and a 24h walk would be wasteful. // is already indexed and a 24h walk would be wasteful.
// Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true. // Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true.
SkipInitialCatalogueRefresh bool SkipInitialCatalogueRefresh bool
// CatalogueRequestDelay is the base delay inserted between per-book metadata
// requests during a catalogue refresh. A random jitter of up to 50% is added
// on top. Defaults to 2s. Increase to reduce 429 pressure on novelfire.net.
// Controlled by RUNNER_CATALOGUE_REQUEST_DELAY (e.g. "3s", "500ms").
CatalogueRequestDelay time.Duration
} }
// Config is the top-level configuration struct consumed by both binaries. // Config is the top-level configuration struct consumed by both binaries.
type Config struct { type Config struct {
PocketBase PocketBase PocketBase PocketBase
MinIO MinIO MinIO MinIO
Kokoro Kokoro Kokoro Kokoro
PocketTTS PocketTTS PocketTTS PocketTTS
HTTP HTTP LibreTranslate LibreTranslate
Runner Runner HTTP HTTP
Meilisearch Meilisearch Runner Runner
Valkey Valkey Meilisearch Meilisearch
Valkey Valkey
Redis Redis
// LogLevel is one of "debug", "info", "warn", "error". // LogLevel is one of "debug", "info", "warn", "error".
LogLevel string LogLevel string
} }
@@ -147,16 +181,17 @@ func Load() Config {
}, },
MinIO: MinIO{ MinIO: MinIO{
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
UseSSL: envBool("MINIO_USE_SSL", false), UseSSL: envBool("MINIO_USE_SSL", false),
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true), PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"), BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"), BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"), BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"), BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
BucketTranslations: envOr("MINIO_BUCKET_TRANSLATIONS", "translations"),
}, },
Kokoro: Kokoro{ Kokoro: Kokoro{
@@ -176,12 +211,14 @@ func Load() Config {
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second), PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1), MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1), MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
MaxConcurrentTranslation: envInt("RUNNER_MAX_CONCURRENT_TRANSLATION", 1),
WorkerID: envOr("RUNNER_WORKER_ID", workerID), WorkerID: envOr("RUNNER_WORKER_ID", workerID),
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU() Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second), Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"), MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0), CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false), SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false),
CatalogueRequestDelay: envDuration("RUNNER_CATALOGUE_REQUEST_DELAY", 2*time.Second),
}, },
Meilisearch: Meilisearch{ Meilisearch: Meilisearch{
@@ -192,6 +229,11 @@ func Load() Config {
Valkey: Valkey{ Valkey: Valkey{
Addr: envOr("VALKEY_ADDR", ""), Addr: envOr("VALKEY_ADDR", ""),
}, },
Redis: Redis{
Addr: envOr("REDIS_ADDR", ""),
Password: envOr("REDIS_PASSWORD", ""),
},
} }
} }

View File

@@ -60,6 +60,20 @@ type RankingItem struct {
Updated time.Time `json:"updated,omitempty"` Updated time.Time `json:"updated,omitempty"`
} }
// ── Voice types ───────────────────────────────────────────────────────────────
// Voice describes a single text-to-speech voice available in the system.
type Voice struct {
// ID is the voice identifier passed to TTS clients (e.g. "af_bella", "alba").
ID string `json:"id"`
// Engine is "kokoro" or "pocket-tts".
Engine string `json:"engine"`
// Lang is the primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr").
Lang string `json:"lang"`
// Gender is "f" or "m".
Gender string `json:"gender"`
}
// ── Storage record types ────────────────────────────────────────────────────── // ── Storage record types ──────────────────────────────────────────────────────
// ChapterInfo is a lightweight chapter descriptor stored in the index. // ChapterInfo is a lightweight chapter descriptor stored in the index.
@@ -135,3 +149,23 @@ type AudioResult struct {
ObjectKey string `json:"object_key,omitempty"` ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
} }
// TranslationTask represents a machine-translation job stored in PocketBase.
type TranslationTask struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "{slug}/{chapter}/{lang}"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Lang string `json:"lang"`
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// TranslationResult is the outcome reported by the runner after finishing a TranslationTask.
type TranslationResult struct {
ObjectKey string `json:"object_key,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}

View File

@@ -0,0 +1,181 @@
// Package libretranslate provides an HTTP client for a self-hosted
// LibreTranslate instance. It handles text chunking, concurrent translation,
// and reassembly so callers can pass arbitrarily long markdown strings.
package libretranslate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
const (
// maxChunkBytes is the target maximum size of each chunk sent to
// LibreTranslate. LibreTranslate's default limit is 5000 characters;
// we stay comfortably below that.
maxChunkBytes = 4500
// concurrency is the number of simultaneous translation requests per chapter.
concurrency = 3
)
// Client translates text via LibreTranslate.
// A nil Client is valid — all calls return the original text unchanged.
type Client interface {
// Translate translates text from sourceLang to targetLang.
// text is a raw markdown string. The returned string is the translated
// markdown, reassembled in original paragraph order.
Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error)
}
// New returns a Client for the given LibreTranslate URL.
// Returns nil when url is empty, which disables translation.
func New(url, apiKey string) Client {
if url == "" {
return nil
}
return &httpClient{
url: strings.TrimRight(url, "/"),
apiKey: apiKey,
http: &http.Client{Timeout: 60 * time.Second},
}
}
type httpClient struct {
url string
apiKey string
http *http.Client
}
// Translate splits text into paragraph chunks, translates them concurrently
// (up to concurrency goroutines), and reassembles in order.
func (c *httpClient) Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
paragraphs := splitParagraphs(text)
if len(paragraphs) == 0 {
return text, nil
}
chunks := binChunks(paragraphs, maxChunkBytes)
translated := make([]string, len(chunks))
errs := make([]error, len(chunks))
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
for i, chunk := range chunks {
wg.Add(1)
sem <- struct{}{}
go func(idx int, chunkText string) {
defer wg.Done()
defer func() { <-sem }()
result, err := c.translateChunk(ctx, chunkText, sourceLang, targetLang)
translated[idx] = result
errs[idx] = err
}(i, chunk)
}
wg.Wait()
for _, err := range errs {
if err != nil {
return "", err
}
}
return strings.Join(translated, "\n\n"), nil
}
// translateChunk sends a single POST /translate request.
func (c *httpClient) translateChunk(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
reqBody := map[string]string{
"q": text,
"source": sourceLang,
"target": targetLang,
"format": "html",
}
if c.apiKey != "" {
reqBody["api_key"] = c.apiKey
}
b, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("libretranslate: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/translate", bytes.NewReader(b))
if err != nil {
return "", fmt.Errorf("libretranslate: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("libretranslate: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errBody struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&errBody)
return "", fmt.Errorf("libretranslate: status %d: %s", resp.StatusCode, errBody.Error)
}
var result struct {
TranslatedText string `json:"translatedText"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("libretranslate: decode response: %w", err)
}
return result.TranslatedText, nil
}
// splitParagraphs splits markdown text on blank lines, preserving non-empty paragraphs.
func splitParagraphs(text string) []string {
// Normalise line endings.
text = strings.ReplaceAll(text, "\r\n", "\n")
// Split on double newlines (blank lines between paragraphs).
parts := strings.Split(text, "\n\n")
var paragraphs []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
paragraphs = append(paragraphs, p)
}
}
return paragraphs
}
// binChunks groups paragraphs into chunks each at most maxBytes in length.
// Each chunk is a single string with paragraphs joined by "\n\n".
func binChunks(paragraphs []string, maxBytes int) []string {
var chunks []string
var current strings.Builder
for _, p := range paragraphs {
needed := len(p)
if current.Len() > 0 {
needed += 2 // for the "\n\n" separator
}
if current.Len()+needed > maxBytes && current.Len() > 0 {
// Flush current chunk.
chunks = append(chunks, current.String())
current.Reset()
}
if current.Len() > 0 {
current.WriteString("\n\n")
}
current.WriteString(p)
}
if current.Len() > 0 {
chunks = append(chunks, current.String())
}
return chunks
}

View File

@@ -13,6 +13,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand"
"net/url" "net/url"
"path" "path"
"strconv" "strconv"
@@ -55,6 +56,9 @@ func (s *Scraper) SourceName() string { return "novelfire.net" }
// ── CatalogueProvider ───────────────────────────────────────────────────────── // ── CatalogueProvider ─────────────────────────────────────────────────────────
// ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages. // ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages.
// Each page fetch uses retryGet with 429-aware exponential backoff.
// A small inter-page delay (cataloguePageDelay) is inserted between requests to
// avoid hammering the server when paging through hundreds of catalogue pages.
func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) { func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
entries := make(chan domain.CatalogueEntry, 64) entries := make(chan domain.CatalogueEntry, 64)
errs := make(chan error, 16) errs := make(chan error, 16)
@@ -73,8 +77,18 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
default: default:
} }
// Polite inter-page delay — skipped on the very first page.
if page > 1 {
jitter := time.Duration(500+rand.Intn(1000)) * time.Millisecond
select {
case <-ctx.Done():
return
case <-time.After(jitter):
}
}
s.log.Info("scraping catalogue page", "page", page, "url", pageURL) s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
raw, err := s.client.GetContent(ctx, pageURL) raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 10*time.Second)
if err != nil { if err != nil {
errs <- fmt.Errorf("catalogue page %d: %w", page, err) errs <- fmt.Errorf("catalogue page %d: %w", page, err)
return return
@@ -139,10 +153,11 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
// ── MetadataProvider ────────────────────────────────────────────────────────── // ── MetadataProvider ──────────────────────────────────────────────────────────
// ScrapeMetadata fetches and parses book metadata from the book's landing page. // ScrapeMetadata fetches and parses book metadata from the book's landing page.
// Uses retryGet with 429-aware exponential backoff (up to 9 attempts).
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) { func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) {
s.log.Debug("metadata fetch starting", "url", bookURL) s.log.Debug("metadata fetch starting", "url", bookURL)
raw, err := s.client.GetContent(ctx, bookURL) raw, err := retryGet(ctx, s.log, s.client, bookURL, 9, 10*time.Second)
if err != nil { if err != nil {
return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err) return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err)
} }
@@ -163,12 +178,26 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.Bo
} }
} }
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"}) // Status: novelfire renders <strong class="ongoing">Ongoing</strong> (or
// "completed", "hiatus") inside the .header-stats block. We take the text
// content and lowercase it so the index value is always canonical lowercase.
var status string
for _, cls := range []string{"ongoing", "completed", "hiatus"} {
if v := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "strong", Class: cls}); v != "" {
status = strings.ToLower(strings.TrimSpace(v))
break
}
}
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"}) // Genres: novelfire renders <div class="categories"><ul><li><a class="property-item">Genre</a>
// Each <a class="property-item"> is one genre tag. Lowercase for index consistency.
var genres []string var genres []string
if genresNode != nil { if categoriesNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "categories"}); categoriesNode != nil {
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true}) for _, v := range htmlutil.ExtractAll(categoriesNode, scraper.Selector{Tag: "a", Class: "property-item", Multiple: true}) {
if v != "" {
genres = append(genres, strings.ToLower(strings.TrimSpace(v)))
}
}
} }
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"}) summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})

View File

@@ -2,6 +2,7 @@ package novelfire
import ( import (
"context" "context"
"log/slog"
"testing" "testing"
) )
@@ -100,6 +101,56 @@ func TestRetryGet_EventualSuccess(t *testing.T) {
} }
} }
// TestParseMetadataSelectors verifies that the status and genres selectors
// match the current novelfire.net HTML structure.
func TestParseMetadataSelectors(t *testing.T) {
// Minimal HTML reproducing the relevant novelfire.net book page structure.
const html = `<!DOCTYPE html>
<html><body>
<h1 class="novel-title">Shadow Slave</h1>
<span class="author">Guiltythree</span>
<figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure>
<div class="header-stats">
<span><strong>123</strong><small>Chapters</small></span>
<span> <strong class="ongoing">Ongoing</strong> <small>Status</small></span>
</div>
<div class="categories">
<h4>Genres</h4>
<ul>
<li><a href="/genre-fantasy/..." class="property-item">Fantasy</a></li>
<li><a href="/genre-action/..." class="property-item">Action</a></li>
<li><a href="/genre-adventure/..." class="property-item">Adventure</a></li>
</ul>
</div>
<span class="chapter-count">123 Chapters</span>
</body></html>`
stub := newStubClient()
stub.setFn("https://novelfire.net/book/shadow-slave", func() (string, error) {
return html, nil
})
s := &Scraper{client: stub, log: slog.Default()}
meta, err := s.ScrapeMetadata(t.Context(), "https://novelfire.net/book/shadow-slave")
if err != nil {
t.Fatalf("ScrapeMetadata: %v", err)
}
if meta.Status != "ongoing" {
t.Errorf("status = %q, want %q", meta.Status, "ongoing")
}
wantGenres := []string{"fantasy", "action", "adventure"}
if len(meta.Genres) != len(wantGenres) {
t.Fatalf("genres = %v, want %v", meta.Genres, wantGenres)
}
for i, g := range meta.Genres {
if g != wantGenres[i] {
t.Errorf("genres[%d] = %q, want %q", i, g, wantGenres[i])
}
}
}
// ── minimal stub client for tests ───────────────────────────────────────────── // ── minimal stub client for tests ─────────────────────────────────────────────
type stubClient struct { type stubClient struct {

View File

@@ -2,7 +2,10 @@
// //
// It reads two environment variables: // It reads two environment variables:
// //
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP endpoint, e.g. http://otel-collector:4318 // OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP endpoint; accepts either a full
// URL ("https://otel.example.com") or a bare
// host[:port] ("otel-collector:4318").
// TLS is used when the value starts with "https://".
// OTEL_SERVICE_NAME — service name reported in traces (default: "backend") // OTEL_SERVICE_NAME — service name reported in traces (default: "backend")
// //
// When OTEL_EXPORTER_OTLP_ENDPOINT is empty the function is a no-op: it // When OTEL_EXPORTER_OTLP_ENDPOINT is empty the function is a no-op: it
@@ -21,6 +24,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
@@ -41,11 +45,17 @@ import (
// - logger: an slog.Logger bridged to OTel logs (falls back to default when disabled). // - logger: an slog.Logger bridged to OTel logs (falls back to default when disabled).
// - err: non-nil only on SDK initialisation failure. // - err: non-nil only on SDK initialisation failure.
func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Logger, err error) { func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Logger, err error) {
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") rawEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
if endpoint == "" { if rawEndpoint == "" {
return nil, slog.Default(), nil // OTel disabled — not an error return nil, slog.Default(), nil // OTel disabled — not an error
} }
// WithEndpoint expects a host[:port] value — no scheme.
// Support both "https://otel.example.com" and "otel-collector:4318".
useTLS := strings.HasPrefix(rawEndpoint, "https://")
endpoint := strings.TrimPrefix(rawEndpoint, "https://")
endpoint = strings.TrimPrefix(endpoint, "http://")
serviceName := os.Getenv("OTEL_SERVICE_NAME") serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" { if serviceName == "" {
serviceName = "backend" serviceName = "backend"
@@ -63,10 +73,11 @@ func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Lo
} }
// ── Trace provider ──────────────────────────────────────────────────────── // ── Trace provider ────────────────────────────────────────────────────────
traceExp, err := otlptracehttp.New(ctx, traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(endpoint)}
otlptracehttp.WithEndpoint(endpoint), if !useTLS {
otlptracehttp.WithInsecure(), // collector is on the internal Docker network traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
) }
traceExp, err := otlptracehttp.New(ctx, traceOpts...)
if err != nil { if err != nil {
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP trace exporter: %w", err) return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP trace exporter: %w", err)
} }
@@ -79,10 +90,11 @@ func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Lo
otel.SetTracerProvider(tp) otel.SetTracerProvider(tp)
// ── Log provider ────────────────────────────────────────────────────────── // ── Log provider ──────────────────────────────────────────────────────────
logExp, err := otlploghttp.New(ctx, logOpts := []otlploghttp.Option{otlploghttp.WithEndpoint(endpoint)}
otlploghttp.WithEndpoint(endpoint), if !useTLS {
otlploghttp.WithInsecure(), logOpts = append(logOpts, otlploghttp.WithInsecure())
) }
logExp, err := otlploghttp.New(ctx, logOpts...)
if err != nil { if err != nil {
return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP log exporter: %w", err) return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP log exporter: %w", err)
} }

View File

@@ -0,0 +1,236 @@
package runner
// asynq_runner.go — Asynq-based task dispatch for the runner.
//
// When cfg.RedisAddr is set, Run() calls runAsynq() instead of runPoll().
// The Asynq server replaces the polling loop: it listens on Redis for tasks
// enqueued by the backend Producer and delivers them immediately.
//
// Handlers in this file decode Asynq job payloads and call the existing
// runScrapeTask / runAudioTask methods, keeping all execution logic in one place.
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/hibiken/asynq"
asynqmetrics "github.com/hibiken/asynq/x/metrics"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/domain"
)
// runAsynq starts an Asynq server that replaces the PocketBase poll loop.
// It also starts the periodic catalogue refresh ticker.
// Blocks until ctx is cancelled.
func (r *Runner) runAsynq(ctx context.Context) error {
redisOpt, err := r.redisConnOpt()
if err != nil {
return fmt.Errorf("runner: parse redis addr: %w", err)
}
srv := asynq.NewServer(redisOpt, asynq.Config{
// Allocate concurrency slots for each task type.
// Total concurrency = scrape + audio slots.
Concurrency: r.cfg.MaxConcurrentScrape + r.cfg.MaxConcurrentAudio,
Queues: map[string]int{
asynqqueue.QueueDefault: 1,
},
// Let Asynq handle retries with exponential back-off.
RetryDelayFunc: asynq.DefaultRetryDelayFunc,
// Log errors from handlers via the existing structured logger.
ErrorHandler: asynq.ErrorHandlerFunc(func(_ context.Context, task *asynq.Task, err error) {
r.deps.Log.Error("runner: asynq task failed",
"type", task.Type(),
"err", err,
)
}),
})
mux := asynq.NewServeMux()
mux.HandleFunc(asynqqueue.TypeAudioGenerate, r.handleAudioTask)
mux.HandleFunc(asynqqueue.TypeScrapeBook, r.handleScrapeTask)
mux.HandleFunc(asynqqueue.TypeScrapeCatalogue, r.handleScrapeTask)
// Register Asynq queue metrics with the default Prometheus registry so
// the /metrics endpoint (metrics.go) can expose them.
inspector := asynq.NewInspector(redisOpt)
collector := asynqmetrics.NewQueueMetricsCollector(inspector)
if err := r.metricsRegistry.Register(collector); err != nil {
r.deps.Log.Warn("runner: could not register asynq prometheus collector", "err", err)
}
// Start the periodic catalogue refresh.
catalogueTick := time.NewTicker(r.cfg.CatalogueRefreshInterval)
defer catalogueTick.Stop()
if !r.cfg.SkipInitialCatalogueRefresh {
go r.runCatalogueRefresh(ctx)
} else {
r.deps.Log.Info("runner: skipping initial catalogue refresh (RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true)")
}
r.deps.Log.Info("runner: asynq mode active", "redis_addr", r.cfg.RedisAddr)
// ── Heartbeat goroutine ──────────────────────────────────────────────
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
// This mirrors the heartbeat file behavior from the poll() loop.
go func() {
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
defer heartbeatTick.Stop()
for {
select {
case <-ctx.Done():
return
case <-heartbeatTick.C:
if f, err := os.Create("/tmp/runner.alive"); err != nil {
r.deps.Log.Warn("runner: could not write heartbeat file", "err", err)
} else {
f.Close()
}
}
}
}()
// ── Translation polling goroutine ────────────────────────────────────
// Translation tasks live in PocketBase (not Redis), so we need a separate
// poll loop to claim and dispatch them. This runs alongside the Asynq server.
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
var translationWg sync.WaitGroup
go func() {
tick := time.NewTicker(r.cfg.PollInterval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
r.pollTranslationTasks(ctx, translationSem, &translationWg)
}
}
}()
// Run catalogue refresh ticker in the background.
go func() {
for {
select {
case <-ctx.Done():
return
case <-catalogueTick.C:
go r.runCatalogueRefresh(ctx)
}
}
}()
// Start Asynq server (non-blocking).
if err := srv.Start(mux); err != nil {
return fmt.Errorf("runner: asynq server start: %w", err)
}
// Block until context is cancelled, then gracefully stop.
<-ctx.Done()
r.deps.Log.Info("runner: context cancelled, shutting down asynq server")
srv.Shutdown()
// Wait for translation tasks to complete.
translationWg.Wait()
return nil
}
// redisConnOpt parses cfg.RedisAddr into an asynq.RedisConnOpt.
// Supports full "redis://" / "rediss://" URLs and plain "host:port".
func (r *Runner) redisConnOpt() (asynq.RedisConnOpt, error) {
addr := r.cfg.RedisAddr
// ParseRedisURI handles redis:// and rediss:// schemes.
if len(addr) > 7 && (addr[:8] == "redis://" || addr[:9] == "rediss://") {
return asynq.ParseRedisURI(addr)
}
// Plain "host:port" — use RedisClientOpt directly.
return asynq.RedisClientOpt{
Addr: addr,
Password: r.cfg.RedisPassword,
}, nil
}
// handleScrapeTask is the Asynq handler for TypeScrapeBook and TypeScrapeCatalogue.
func (r *Runner) handleScrapeTask(ctx context.Context, t *asynq.Task) error {
var p asynqqueue.ScrapePayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal scrape payload: %w", err)
}
task := domain.ScrapeTask{
ID: p.PBTaskID,
Kind: p.Kind,
TargetURL: p.TargetURL,
FromChapter: p.FromChapter,
ToChapter: p.ToChapter,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)
r.runScrapeTask(ctx, task)
return nil
}
// handleAudioTask is the Asynq handler for TypeAudioGenerate.
func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
var p asynqqueue.AudioPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("unmarshal audio payload: %w", err)
}
task := domain.AudioTask{
ID: p.PBTaskID,
Slug: p.Slug,
Chapter: p.Chapter,
Voice: p.Voice,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)
r.runAudioTask(ctx, task)
return nil
}
// pollTranslationTasks claims all available translation tasks from PocketBase
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
// because they're stored in PocketBase, so we need this separate poll loop.
func (r *Runner) pollTranslationTasks(ctx context.Context, translationSem chan struct{}, wg *sync.WaitGroup) {
// Reap orphaned tasks (same logic as poll() in runner.go).
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
r.deps.Log.Warn("runner: reap stale translation tasks failed", "err", err)
} else if n > 0 {
r.deps.Log.Info("runner: reaped stale translation tasks", "count", n)
}
translationLoop:
for {
if ctx.Err() != nil {
return
}
select {
case translationSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break translationLoop
}
task, ok, err := r.deps.Consumer.ClaimNextTranslationTask(ctx, r.cfg.WorkerID)
if err != nil {
<-translationSem
r.deps.Log.Error("runner: ClaimNextTranslationTask failed", "err", err)
break
}
if !ok {
<-translationSem
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.TranslationTask) {
defer wg.Done()
defer func() { <-translationSem }()
defer r.tasksRunning.Add(-1)
r.runTranslationTask(ctx, t)
}(task)
}
}

View File

@@ -6,17 +6,20 @@ package runner
// //
// Design: // Design:
// - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run(). // - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run().
// - Also fires once on startup. // - Also fires once on startup (unless SkipInitialCatalogueRefresh is set).
// - ScrapeCatalogue streams CatalogueEntry values over a channel — we iterate // - ScrapeCatalogue streams CatalogueEntry values over a channel — already has
// and call ScrapeMetadata for each entry. // its own inter-page jitter + retryGet (see scraper.go).
// - Per-request random jitter (13s) prevents hammering novelfire.net. // - Per-book: only metadata is scraped here (not chapters). Chapters are scraped
// - Cover images are fetched from the URL embedded in BookMeta.Cover and // on-demand when a user opens a book or via an explicit scrape task.
// stored in MinIO (browse bucket, key: covers/{slug}.jpg). // - Between each metadata request a configurable base delay plus up to 50%
// - WriteMetadata + UpsertBook are called for every successfully scraped book. // random jitter is applied (CatalogueRequestDelay, default 2s). This keeps
// - Errors for individual books are logged and skipped; the loop continues. // the request rate well below novelfire.net's rate limit even for ~15k books.
// - The cover URL stored in BookMeta.Cover is rewritten to the internal proxy // - ScrapeMetadata itself uses retryGet with 429-aware exponential backoff
// path (/api/cover/novelfire.net/{slug}) so the UI always fetches via the // (up to 9 attempts), so transient rate limits are handled gracefully.
// backend, which will serve from MinIO. // - Cover images are fetched and stored in MinIO on first sight; subsequent
// refreshes skip covers that already exist (CoverExists check).
// - Books already present in Meilisearch are skipped entirely (fast path).
// - Errors for individual books are logged and skipped; the loop never aborts.
import ( import (
"context" "context"
@@ -29,7 +32,7 @@ import (
// runCatalogueRefresh performs one full catalogue walk: scrapes metadata for // runCatalogueRefresh performs one full catalogue walk: scrapes metadata for
// every book on novelfire.net, downloads covers to MinIO, and upserts to // every book on novelfire.net, downloads covers to MinIO, and upserts to
// Meilisearch. Errors for individual books are logged and skipped. // Meilisearch. Individual book failures are logged and skipped.
func (r *Runner) runCatalogueRefresh(ctx context.Context) { func (r *Runner) runCatalogueRefresh(ctx context.Context) {
if r.deps.Novel == nil { if r.deps.Novel == nil {
r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured") r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured")
@@ -40,8 +43,9 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
return return
} }
delay := r.cfg.CatalogueRequestDelay
log := r.deps.Log.With("op", "catalogue_refresh") log := r.deps.Log.With("op", "catalogue_refresh")
log.Info("runner: catalogue refresh starting") log.Info("runner: catalogue refresh starting", "request_delay", delay)
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx) entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
@@ -51,26 +55,26 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
break break
} }
// Skip books already present in Meilisearch — they were indexed on a // Fast path: skip books already indexed in Meilisearch.
// previous run. Re-indexing only happens when a scrape task is
// explicitly enqueued (e.g. via the admin UI or API).
if r.deps.SearchIndex.BookExists(ctx, entry.Slug) { if r.deps.SearchIndex.BookExists(ctx, entry.Slug) {
skipped++ skipped++
continue continue
} }
// Random jitter between books to avoid rate-limiting. // Polite delay between metadata requests: base + up to 50% jitter.
jitter := time.Duration(1000+rand.Intn(2000)) * time.Millisecond // This applies before every fetch so we never fire bursts.
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
select { select {
case <-ctx.Done(): case <-ctx.Done():
break break
case <-time.After(jitter): case <-time.After(delay + jitter):
} }
// ScrapeMetadata internally retries on 429 with exponential back-off.
meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL) meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL)
if err != nil { if err != nil {
log.Warn("runner: catalogue refresh: metadata scrape failed", log.Warn("runner: catalogue refresh: metadata scrape failed — skipping book",
"url", entry.URL, "err", err) "slug", entry.Slug, "url", entry.URL, "err", err)
errCount++ errCount++
continue continue
} }
@@ -81,35 +85,32 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
// Persist to PocketBase. // Persist to PocketBase.
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil { if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: WriteMetadata failed", log.Warn("runner: catalogue refresh: WriteMetadata failed — skipping book",
"slug", meta.Slug, "err", err) "slug", meta.Slug, "err", err)
errCount++ errCount++
continue continue
} }
// Index in Meilisearch. // Index in Meilisearch (non-fatal).
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil { if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
log.Warn("runner: catalogue refresh: UpsertBook failed", log.Warn("runner: catalogue refresh: UpsertBook failed",
"slug", meta.Slug, "err", err) "slug", meta.Slug, "err", err)
// non-fatal — continue
} }
// Download and store cover image in MinIO if we have a cover URL // Download cover to MinIO if not already cached (non-fatal).
// and a CoverStore is wired in.
if r.deps.CoverStore != nil && originalCover != "" { if r.deps.CoverStore != nil && originalCover != "" {
if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) { if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) {
if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil { if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil {
log.Warn("runner: catalogue refresh: cover download failed", log.Warn("runner: catalogue refresh: cover download failed",
"slug", meta.Slug, "url", originalCover, "err", err) "slug", meta.Slug, "url", originalCover, "err", err)
// non-fatal
} }
} }
} }
ok++ ok++
if ok%100 == 0 { if ok%50 == 0 {
log.Info("runner: catalogue refresh progress", log.Info("runner: catalogue refresh progress",
"scraped", ok, "errors", errCount) "scraped", ok, "skipped", skipped, "errors", errCount)
} }
} }

View File

@@ -1,21 +1,28 @@
package runner package runner
// metrics.go — lightweight HTTP metrics endpoint for the runner. // metrics.go — Prometheus metrics HTTP endpoint for the runner.
// //
// GET /metrics returns a JSON document with live task counters and uptime. // GET /metrics returns a Prometheus text/plain scrape response.
// No external dependency (no Prometheus); plain net/http only. // Exposes:
// - Standard Go runtime metrics (via promhttp)
// - Runner task counters (tasks_running, tasks_completed, tasks_failed)
// - Asynq queue metrics (registered in asynq_runner.go when Redis is enabled)
//
// GET /health — simple liveness probe.
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
) )
// metricsServer serves GET /metrics for the runner process. // metricsServer serves GET /metrics and GET /health for the runner process.
type metricsServer struct { type metricsServer struct {
addr string addr string
r *Runner r *Runner
@@ -23,21 +30,62 @@ type metricsServer struct {
} }
func newMetricsServer(addr string, r *Runner, log *slog.Logger) *metricsServer { func newMetricsServer(addr string, r *Runner, log *slog.Logger) *metricsServer {
return &metricsServer{addr: addr, r: r, log: log} ms := &metricsServer{addr: addr, r: r, log: log}
ms.registerCollectors()
return ms
}
// registerCollectors registers runner-specific Prometheus collectors.
// Called once at construction; Asynq queue collector is registered separately
// in asynq_runner.go after the Redis connection is established.
func (ms *metricsServer) registerCollectors() {
// Runner task gauges / counters backed by the atomic fields on Runner.
ms.r.metricsRegistry.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Namespace: "runner",
Name: "tasks_running",
Help: "Number of tasks currently being processed.",
},
func() float64 { return float64(ms.r.tasksRunning.Load()) },
))
ms.r.metricsRegistry.MustRegister(prometheus.NewCounterFunc(
prometheus.CounterOpts{
Namespace: "runner",
Name: "tasks_completed_total",
Help: "Total number of tasks completed successfully since startup.",
},
func() float64 { return float64(ms.r.tasksCompleted.Load()) },
))
ms.r.metricsRegistry.MustRegister(prometheus.NewCounterFunc(
prometheus.CounterOpts{
Namespace: "runner",
Name: "tasks_failed_total",
Help: "Total number of tasks that ended in failure since startup.",
},
func() float64 { return float64(ms.r.tasksFailed.Load()) },
))
ms.r.metricsRegistry.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Namespace: "runner",
Name: "uptime_seconds",
Help: "Seconds since the runner process started.",
},
func() float64 { return time.Since(ms.r.startedAt).Seconds() },
))
} }
// ListenAndServe starts the HTTP server and blocks until ctx is cancelled or // ListenAndServe starts the HTTP server and blocks until ctx is cancelled or
// a fatal listen error occurs. // a fatal listen error occurs.
func (ms *metricsServer) ListenAndServe(ctx context.Context) error { func (ms *metricsServer) ListenAndServe(ctx context.Context) error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /metrics", ms.handleMetrics) mux.Handle("GET /metrics", promhttp.HandlerFor(ms.r.metricsRegistry, promhttp.HandlerOpts{}))
mux.HandleFunc("GET /health", ms.handleHealth) mux.HandleFunc("GET /health", ms.handleHealth)
srv := &http.Server{ srv := &http.Server{
Addr: ms.addr, Addr: ms.addr,
Handler: mux, Handler: mux,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second,
BaseContext: func(_ net.Listener) context.Context { return ctx }, BaseContext: func(_ net.Listener) context.Context { return ctx },
} }
@@ -58,35 +106,8 @@ func (ms *metricsServer) ListenAndServe(ctx context.Context) error {
} }
} }
// handleMetrics handles GET /metrics. // handleHealth handles GET /health — simple liveness probe.
// Response shape (JSON):
//
// {
// "tasks_running": N,
// "tasks_completed": N,
// "tasks_failed": N,
// "uptime_seconds": N
// }
func (ms *metricsServer) handleMetrics(w http.ResponseWriter, _ *http.Request) {
uptimeSec := int64(time.Since(ms.r.startedAt).Seconds())
metricsWriteJSON(w, 0, map[string]int64{
"tasks_running": ms.r.tasksRunning.Load(),
"tasks_completed": ms.r.tasksCompleted.Load(),
"tasks_failed": ms.r.tasksFailed.Load(),
"uptime_seconds": uptimeSec,
})
}
// handleHealth handles GET /health — simple liveness probe for the metrics server.
func (ms *metricsServer) handleHealth(w http.ResponseWriter, _ *http.Request) { func (ms *metricsServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
metricsWriteJSON(w, 0, map[string]string{"status": "ok"})
}
// metricsWriteJSON writes v as a JSON response with the given status code.
func metricsWriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if status != 0 { _, _ = w.Write([]byte(`{"status":"ok"}`))
w.WriteHeader(status)
}
_ = json.NewEncoder(w).Encode(v)
} }

View File

@@ -29,11 +29,13 @@ import (
"github.com/libnovel/backend/internal/bookstore" "github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain" "github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
"github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/orchestrator" "github.com/libnovel/backend/internal/orchestrator"
"github.com/libnovel/backend/internal/pockettts" "github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper" "github.com/libnovel/backend/internal/scraper"
"github.com/libnovel/backend/internal/taskqueue" "github.com/libnovel/backend/internal/taskqueue"
"github.com/prometheus/client_golang/prometheus"
) )
// Config tunes the runner behaviour. // Config tunes the runner behaviour.
@@ -41,23 +43,32 @@ type Config struct {
// WorkerID uniquely identifies this runner instance in PocketBase records. // WorkerID uniquely identifies this runner instance in PocketBase records.
WorkerID string WorkerID string
// PollInterval is how often the runner checks for new tasks. // PollInterval is how often the runner checks for new tasks.
// Only used in PocketBase-polling mode (RedisAddr == "").
PollInterval time.Duration PollInterval time.Duration
// MaxConcurrentScrape limits simultaneous book-scrape goroutines. // MaxConcurrentScrape limits simultaneous book-scrape goroutines.
MaxConcurrentScrape int MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines. // MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int MaxConcurrentAudio int
// MaxConcurrentTranslation limits simultaneous translation goroutines.
MaxConcurrentTranslation int
// OrchestratorWorkers is the chapter-scraping parallelism inside each book run. // OrchestratorWorkers is the chapter-scraping parallelism inside each book run.
OrchestratorWorkers int OrchestratorWorkers int
// HeartbeatInterval is how often active tasks PATCH their heartbeat_at // HeartbeatInterval is how often active tasks PATCH their heartbeat_at
// timestamp to signal they are still alive. Defaults to 30s when 0. // timestamp to signal they are still alive. Defaults to 30s when 0.
// Only used in PocketBase-polling mode.
HeartbeatInterval time.Duration HeartbeatInterval time.Duration
// StaleTaskThreshold is how old a heartbeat must be (or absent) before the // StaleTaskThreshold is how old a heartbeat must be (or absent) before the
// task is considered orphaned and reset to pending. Defaults to 2m when 0. // task is considered orphaned and reset to pending. Defaults to 2m when 0.
// Only used in PocketBase-polling mode.
StaleTaskThreshold time.Duration StaleTaskThreshold time.Duration
// CatalogueRefreshInterval is how often the runner walks the full catalogue, // CatalogueRefreshInterval is how often the runner walks the full catalogue,
// scrapes per-book metadata, downloads covers, and re-indexes everything in // scrapes per-book metadata, downloads covers, and re-indexes everything in
// Meilisearch. Defaults to 24h (expensive — full catalogue walk). // Meilisearch. Defaults to 24h (expensive — full catalogue walk).
CatalogueRefreshInterval time.Duration CatalogueRefreshInterval time.Duration
// CatalogueRequestDelay is the base inter-request pause during a catalogue
// refresh metadata walk. Jitter of up to 50% is added on top.
// Defaults to 2s. Set via RUNNER_CATALOGUE_REQUEST_DELAY.
CatalogueRequestDelay time.Duration
// SkipInitialCatalogueRefresh suppresses the immediate catalogue walk that // SkipInitialCatalogueRefresh suppresses the immediate catalogue walk that
// otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval) // otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval)
// still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for // still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for
@@ -66,6 +77,15 @@ type Config struct {
// MetricsAddr is the HTTP listen address for the /metrics endpoint. // MetricsAddr is the HTTP listen address for the /metrics endpoint.
// Defaults to ":9091". Set to "" to disable. // Defaults to ":9091". Set to "" to disable.
MetricsAddr string MetricsAddr string
// RedisAddr is the address of the Redis instance used for Asynq task
// dispatch. When set the runner switches from PocketBase-polling mode to
// Asynq ServeMux mode (immediate task delivery, no polling).
// Supports plain "host:port" or a full "rediss://..." URL.
// When empty the runner falls back to PocketBase polling.
RedisAddr string
// RedisPassword is the Redis AUTH password.
// Not required when RedisAddr is a full URL that includes credentials.
RedisPassword string
} }
// Dependencies are the external services the runner depends on. // Dependencies are the external services the runner depends on.
@@ -78,6 +98,8 @@ type Dependencies struct {
BookReader bookstore.BookReader BookReader bookstore.BookReader
// AudioStore persists generated audio and checks key existence. // AudioStore persists generated audio and checks key existence.
AudioStore bookstore.AudioStore AudioStore bookstore.AudioStore
// TranslationStore persists translated markdown and checks key existence.
TranslationStore bookstore.TranslationStore
// CoverStore stores book cover images in MinIO. // CoverStore stores book cover images in MinIO.
CoverStore bookstore.CoverStore CoverStore bookstore.CoverStore
// SearchIndex indexes books in Meilisearch after scraping. // SearchIndex indexes books in Meilisearch after scraping.
@@ -90,6 +112,9 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.). // PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
// If nil, pocket-tts voice tasks will fail with a clear error. // If nil, pocket-tts voice tasks will fail with a clear error.
PocketTTS pockettts.Client PocketTTS pockettts.Client
// LibreTranslate is the machine translation client.
// If nil, translation tasks will fail with a clear error.
LibreTranslate libretranslate.Client
// Log is the structured logger. // Log is the structured logger.
Log *slog.Logger Log *slog.Logger
} }
@@ -99,6 +124,8 @@ type Runner struct {
cfg Config cfg Config
deps Dependencies deps Dependencies
metricsRegistry *prometheus.Registry
// Atomic task counters — read by /metrics without locking. // Atomic task counters — read by /metrics without locking.
tasksRunning atomic.Int64 tasksRunning atomic.Int64
tasksCompleted atomic.Int64 tasksCompleted atomic.Int64
@@ -118,6 +145,9 @@ func New(cfg Config, deps Dependencies) *Runner {
if cfg.MaxConcurrentAudio <= 0 { if cfg.MaxConcurrentAudio <= 0 {
cfg.MaxConcurrentAudio = 1 cfg.MaxConcurrentAudio = 1
} }
if cfg.MaxConcurrentTranslation <= 0 {
cfg.MaxConcurrentTranslation = 1
}
if cfg.WorkerID == "" { if cfg.WorkerID == "" {
cfg.WorkerID = "runner" cfg.WorkerID = "runner"
} }
@@ -130,6 +160,9 @@ func New(cfg Config, deps Dependencies) *Runner {
if cfg.CatalogueRefreshInterval <= 0 { if cfg.CatalogueRefreshInterval <= 0 {
cfg.CatalogueRefreshInterval = 24 * time.Hour cfg.CatalogueRefreshInterval = 24 * time.Hour
} }
if cfg.CatalogueRequestDelay <= 0 {
cfg.CatalogueRequestDelay = 2 * time.Second
}
if cfg.MetricsAddr == "" { if cfg.MetricsAddr == "" {
cfg.MetricsAddr = ":9091" cfg.MetricsAddr = ":9091"
} }
@@ -139,17 +172,21 @@ func New(cfg Config, deps Dependencies) *Runner {
if deps.SearchIndex == nil { if deps.SearchIndex == nil {
deps.SearchIndex = meili.NoopClient{} deps.SearchIndex = meili.NoopClient{}
} }
return &Runner{cfg: cfg, deps: deps, startedAt: time.Now()} return &Runner{cfg: cfg, deps: deps, startedAt: time.Now(), metricsRegistry: prometheus.NewRegistry()}
} }
// Run starts the poll loop and the metrics HTTP server, blocking until ctx is // Run starts the worker loop and the metrics HTTP server, blocking until ctx
// cancelled. // is cancelled.
//
// When cfg.RedisAddr is set the runner uses Asynq (immediate task delivery).
// Otherwise it falls back to PocketBase polling (legacy mode).
func (r *Runner) Run(ctx context.Context) error { func (r *Runner) Run(ctx context.Context) error {
r.deps.Log.Info("runner: starting", r.deps.Log.Info("runner: starting",
"worker_id", r.cfg.WorkerID, "worker_id", r.cfg.WorkerID,
"poll_interval", r.cfg.PollInterval, "mode", r.mode(),
"max_scrape", r.cfg.MaxConcurrentScrape, "max_scrape", r.cfg.MaxConcurrentScrape,
"max_audio", r.cfg.MaxConcurrentAudio, "max_audio", r.cfg.MaxConcurrentAudio,
"max_translation", r.cfg.MaxConcurrentTranslation,
"catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval, "catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval,
"metrics_addr", r.cfg.MetricsAddr, "metrics_addr", r.cfg.MetricsAddr,
) )
@@ -164,8 +201,26 @@ func (r *Runner) Run(ctx context.Context) error {
}() }()
} }
if r.cfg.RedisAddr != "" {
return r.runAsynq(ctx)
}
return r.runPoll(ctx)
}
// mode returns a short string describing the active dispatch mode.
func (r *Runner) mode() string {
if r.cfg.RedisAddr != "" {
return "asynq"
}
return "poll"
}
// runPoll is the legacy PocketBase-polling dispatch loop.
// Used when cfg.RedisAddr is empty.
func (r *Runner) runPoll(ctx context.Context) error {
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape) scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio) audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
var wg sync.WaitGroup var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval) tick := time.NewTicker(r.cfg.PollInterval)
@@ -181,9 +236,11 @@ func (r *Runner) Run(ctx context.Context) error {
r.deps.Log.Info("runner: skipping initial catalogue refresh (RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true)") r.deps.Log.Info("runner: skipping initial catalogue refresh (RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true)")
} }
r.deps.Log.Info("runner: poll mode active", "poll_interval", r.cfg.PollInterval)
// Run one poll immediately on startup, then on each tick. // Run one poll immediately on startup, then on each tick.
for { for {
r.poll(ctx, scrapeSem, audioSem, &wg) r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -208,7 +265,7 @@ func (r *Runner) Run(ctx context.Context) error {
} }
// poll claims all available pending tasks and dispatches them to goroutines. // poll claims all available pending tasks and dispatches them to goroutines.
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) { func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem chan struct{}, wg *sync.WaitGroup) {
// ── Heartbeat file ──────────────────────────────────────────────────── // ── Heartbeat file ────────────────────────────────────────────────────
// Touch /tmp/runner.alive so the Docker health check can confirm the // Touch /tmp/runner.alive so the Docker health check can confirm the
// runner is actively polling. Failure is non-fatal — just log it. // runner is actively polling. Failure is non-fatal — just log it.
@@ -291,6 +348,39 @@ audioLoop:
r.runAudioTask(ctx, t) r.runAudioTask(ctx, t)
}(task) }(task)
} }
// ── Translation tasks ─────────────────────────────────────────────────
translationLoop:
for {
if ctx.Err() != nil {
return
}
select {
case translationSem <- struct{}{}:
// Slot acquired — proceed to claim a task.
default:
// All slots busy; leave remaining pending tasks for next tick.
break translationLoop
}
task, ok, err := r.deps.Consumer.ClaimNextTranslationTask(ctx, r.cfg.WorkerID)
if err != nil {
<-translationSem
r.deps.Log.Error("runner: ClaimNextTranslationTask failed", "err", err)
break
}
if !ok {
<-translationSem
break
}
r.tasksRunning.Add(1)
wg.Add(1)
go func(t domain.TranslationTask) {
defer wg.Done()
defer func() { <-translationSem }()
defer r.tasksRunning.Add(-1)
r.runTranslationTask(ctx, t)
}(task)
}
} }
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in. // newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.

View File

@@ -48,6 +48,10 @@ func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.A
return t, true, nil return t, true, nil
} }
func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error { func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
s.finished = append(s.finished, id) s.finished = append(s.finished, id)
return nil return nil
@@ -58,6 +62,11 @@ func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.Au
return nil return nil
} }
func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ domain.TranslationResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error { func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
s.failCalled = append(s.failCalled, id) s.failCalled = append(s.failCalled, id)
return nil return nil

View File

@@ -0,0 +1,97 @@
package runner
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"github.com/libnovel/backend/internal/domain"
)
// runTranslationTask executes one machine-translation task end-to-end and
// reports the result back to PocketBase.
func (r *Runner) runTranslationTask(ctx context.Context, task domain.TranslationTask) {
ctx, span := otel.Tracer("runner").Start(ctx, "runner.translation_task")
defer span.End()
span.SetAttributes(
attribute.String("task.id", task.ID),
attribute.String("book.slug", task.Slug),
attribute.Int("chapter.number", task.Chapter),
attribute.String("translation.lang", task.Lang),
)
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "lang", task.Lang)
log.Info("runner: translation task starting")
// Heartbeat goroutine — keeps the task alive while translation runs.
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
go func() {
tick := time.NewTicker(r.cfg.HeartbeatInterval)
defer tick.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-tick.C:
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
log.Warn("runner: heartbeat failed", "err", err)
}
}
}
}()
fail := func(msg string) {
log.Error("runner: translation task failed", "reason", msg)
r.tasksFailed.Add(1)
span.SetStatus(codes.Error, msg)
result := domain.TranslationResult{ErrorMessage: msg}
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishTranslationTask failed", "err", err)
}
}
// Guard: LibreTranslate must be configured.
if r.deps.LibreTranslate == nil {
fail("libretranslate client not configured (LIBRETRANSLATE_URL is empty)")
return
}
// 1. Read raw markdown chapter.
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
if err != nil {
fail(fmt.Sprintf("read chapter: %v", err))
return
}
if raw == "" {
fail("chapter text is empty")
return
}
// 2. Translate (chunked, concurrent).
translated, err := r.deps.LibreTranslate.Translate(ctx, raw, "en", task.Lang)
if err != nil {
fail(fmt.Sprintf("translate: %v", err))
return
}
// 3. Store translated markdown in MinIO.
key := r.deps.TranslationStore.TranslationObjectKey(task.Lang, task.Slug, task.Chapter)
if err := r.deps.TranslationStore.PutTranslation(ctx, key, []byte(translated)); err != nil {
fail(fmt.Sprintf("put translation: %v", err))
return
}
// 4. Report success.
r.tasksCompleted.Add(1)
span.SetStatus(codes.Ok, "")
result := domain.TranslationResult{ObjectKey: key}
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishTranslationTask failed", "err", err)
}
log.Info("runner: translation task finished", "key", key)
}

View File

@@ -17,12 +17,13 @@ import (
// minioClient wraps the official minio-go client with bucket names. // minioClient wraps the official minio-go client with bucket names.
type minioClient struct { type minioClient struct {
client *minio.Client // internal — all read/write operations client *minio.Client // internal — all read/write operations
pubClient *minio.Client // presign-only — initialised against the public endpoint pubClient *minio.Client // presign-only — initialised against the public endpoint
bucketChapters string bucketChapters string
bucketAudio string bucketAudio string
bucketAvatars string bucketAvatars string
bucketBrowse string bucketBrowse string
bucketTranslations string
} }
func newMinioClient(cfg config.MinIO) (*minioClient, error) { func newMinioClient(cfg config.MinIO) (*minioClient, error) {
@@ -74,18 +75,19 @@ func newMinioClient(cfg config.MinIO) (*minioClient, error) {
} }
return &minioClient{ return &minioClient{
client: internal, client: internal,
pubClient: pub, pubClient: pub,
bucketChapters: cfg.BucketChapters, bucketChapters: cfg.BucketChapters,
bucketAudio: cfg.BucketAudio, bucketAudio: cfg.BucketAudio,
bucketAvatars: cfg.BucketAvatars, bucketAvatars: cfg.BucketAvatars,
bucketBrowse: cfg.BucketBrowse, bucketBrowse: cfg.BucketBrowse,
bucketTranslations: cfg.BucketTranslations,
}, nil }, nil
} }
// ensureBuckets creates all required buckets if they don't already exist. // ensureBuckets creates all required buckets if they don't already exist.
func (m *minioClient) ensureBuckets(ctx context.Context) error { func (m *minioClient) ensureBuckets(ctx context.Context) error {
for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse} { for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse, m.bucketTranslations} {
exists, err := m.client.BucketExists(ctx, bucket) exists, err := m.client.BucketExists(ctx, bucket)
if err != nil { if err != nil {
return fmt.Errorf("minio: check bucket %q: %w", bucket, err) return fmt.Errorf("minio: check bucket %q: %w", bucket, err)
@@ -125,6 +127,12 @@ func CoverObjectKey(slug string) string {
return fmt.Sprintf("covers/%s.jpg", slug) return fmt.Sprintf("covers/%s.jpg", slug)
} }
// TranslationObjectKey returns the MinIO object key for a translated chapter.
// Format: {lang}/{slug}/{n:06d}.md
func TranslationObjectKey(lang, slug string, n int) string {
return fmt.Sprintf("%s/%s/%06d.md", lang, slug, n)
}
// chapterNumberFromKey extracts the chapter number from a MinIO object key. // chapterNumberFromKey extracts the chapter number from a MinIO object key.
// e.g. "my-book/chapter-000042.md" → 42 // e.g. "my-book/chapter-000042.md" → 42
func chapterNumberFromKey(key string) int { func chapterNumberFromKey(key string) int {

View File

@@ -51,6 +51,7 @@ var _ bookstore.AudioStore = (*Store)(nil)
var _ bookstore.PresignStore = (*Store)(nil) var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil) var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil) var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil) var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil) var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil) var _ taskqueue.Reader = (*Store)(nil)
@@ -535,13 +536,36 @@ func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, v
return rec.ID, nil return rec.ID, nil
} }
func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error) {
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, lang)
payload := map[string]any{
"cache_key": cacheKey,
"slug": slug,
"chapter": chapter,
"lang": lang,
"status": string(domain.TaskStatusPending),
"started": time.Now().UTC().Format(time.RFC3339),
}
var rec struct {
ID string `json:"id"`
}
if err := s.pb.post(ctx, "/api/collections/translation_jobs/records", payload, &rec); err != nil {
return "", err
}
return rec.ID, nil
}
func (s *Store) CancelTask(ctx context.Context, id string) error { func (s *Store) CancelTask(ctx context.Context, id string) error {
// Try scraping_tasks first, then audio_jobs. // Try scraping_tasks first, then audio_jobs, then translation_jobs.
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil { map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
return nil return nil
} }
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id),
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id),
map[string]string{"status": string(domain.TaskStatusCancelled)}) map[string]string{"status": string(domain.TaskStatusCancelled)})
} }
@@ -571,6 +595,18 @@ func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain
return task, err == nil, err return task, err == nil, err
} }
func (s *Store) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
raw, err := s.pb.claimRecord(ctx, "translation_jobs", workerID, nil)
if err != nil {
return domain.TranslationTask{}, false, err
}
if raw == nil {
return domain.TranslationTask{}, false, nil
}
task, err := parseTranslationTask(raw)
return task, err == nil, err
}
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error { func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
status := string(domain.TaskStatusDone) status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" { if result.ErrorMessage != "" {
@@ -599,6 +635,18 @@ func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.Au
}) })
} }
func (s *Store) FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), map[string]any{
"status": status,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error { func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
payload := map[string]any{ payload := map[string]any{
"status": string(domain.TaskStatusFailed), "status": string(domain.TaskStatusFailed),
@@ -608,11 +656,14 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil { if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
return nil return nil
} }
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload) if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
} }
// HeartbeatTask updates the heartbeat_at field on a running task. // HeartbeatTask updates the heartbeat_at field on a running task.
// Tries scraping_tasks first, then audio_jobs (same pattern as FailTask). // Tries scraping_tasks first, then audio_jobs, then translation_jobs.
func (s *Store) HeartbeatTask(ctx context.Context, id string) error { func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
payload := map[string]any{ payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339), "heartbeat_at": time.Now().UTC().Format(time.RFC3339),
@@ -620,7 +671,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil { if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
return nil return nil
} }
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload) if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
return nil
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
} }
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing // ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
@@ -638,7 +692,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
} }
total := 0 total := 0
for _, collection := range []string{"scraping_tasks", "audio_jobs"} { for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
items, err := s.pb.listAll(ctx, collection, filter, "") items, err := s.pb.listAll(ctx, collection, filter, "")
if err != nil { if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err) return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
@@ -706,7 +760,7 @@ func (s *Store) ListAudioTasks(ctx context.Context) ([]domain.AudioTask, error)
} }
func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) { func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) {
filter := fmt.Sprintf(`cache_key=%q`, cacheKey) filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started") items, err := s.pb.listAll(ctx, "audio_jobs", filter, "-started")
if err != nil || len(items) == 0 { if err != nil || len(items) == 0 {
return domain.AudioTask{}, false, err return domain.AudioTask{}, false, err
@@ -715,6 +769,31 @@ func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.Audio
return t, err == nil, err return t, err == nil, err
} }
func (s *Store) ListTranslationTasks(ctx context.Context) ([]domain.TranslationTask, error) {
items, err := s.pb.listAll(ctx, "translation_jobs", "", "-started")
if err != nil {
return nil, err
}
tasks := make([]domain.TranslationTask, 0, len(items))
for _, raw := range items {
t, err := parseTranslationTask(raw)
if err == nil {
tasks = append(tasks, t)
}
}
return tasks, nil
}
func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error) {
filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
items, err := s.pb.listAll(ctx, "translation_jobs", filter, "-started")
if err != nil || len(items) == 0 {
return domain.TranslationTask{}, false, err
}
t, err := parseTranslationTask(items[0])
return t, err == nil, err
}
// ── Parsers ─────────────────────────────────────────────────────────────────── // ── Parsers ───────────────────────────────────────────────────────────────────
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) { func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
@@ -789,6 +868,38 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
}, nil }, nil
} }
func parseTranslationTask(raw json.RawMessage) (domain.TranslationTask, error) {
var rec struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Lang string `json:"lang"`
WorkerID string `json:"worker_id"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
if err := json.Unmarshal(raw, &rec); err != nil {
return domain.TranslationTask{}, err
}
started, _ := time.Parse(time.RFC3339, rec.Started)
finished, _ := time.Parse(time.RFC3339, rec.Finished)
return domain.TranslationTask{
ID: rec.ID,
CacheKey: rec.CacheKey,
Slug: rec.Slug,
Chapter: rec.Chapter,
Lang: rec.Lang,
WorkerID: rec.WorkerID,
Status: domain.TaskStatus(rec.Status),
ErrorMessage: rec.ErrorMessage,
Started: started,
Finished: finished,
}, nil
}
// ── CoverStore ───────────────────────────────────────────────────────────────── // ── CoverStore ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error { func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
@@ -818,3 +929,25 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
func (s *Store) CoverExists(ctx context.Context, slug string) bool { func (s *Store) CoverExists(ctx context.Context, slug string) bool {
return s.mc.coverExists(ctx, CoverObjectKey(slug)) return s.mc.coverExists(ctx, CoverObjectKey(slug))
} }
// ── TranslationStore ───────────────────────────────────────────────────────────
func (s *Store) TranslationObjectKey(lang, slug string, n int) string {
return TranslationObjectKey(lang, slug, n)
}
func (s *Store) TranslationExists(ctx context.Context, key string) bool {
return s.mc.objectExists(ctx, s.mc.bucketTranslations, key)
}
func (s *Store) PutTranslation(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, s.mc.bucketTranslations, key, "text/markdown; charset=utf-8", data)
}
func (s *Store) GetTranslation(ctx context.Context, key string) (string, error) {
data, err := s.mc.getObject(ctx, s.mc.bucketTranslations, key)
if err != nil {
return "", fmt.Errorf("GetTranslation: %w", err)
}
return string(data), nil
}

View File

@@ -29,6 +29,10 @@ type Producer interface {
// returns the assigned PocketBase record ID. // returns the assigned PocketBase record ID.
CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error) CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error)
// CreateTranslationTask inserts a new translation task with status=pending and
// returns the assigned PocketBase record ID.
CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error)
// CancelTask transitions a pending task to status=cancelled. // CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist. // Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error CancelTask(ctx context.Context, id string) error
@@ -46,13 +50,21 @@ type Consumer interface {
// Returns (zero, false, nil) when the queue is empty. // Returns (zero, false, nil) when the queue is empty.
ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error) ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error)
// ClaimNextTranslationTask atomically finds the oldest pending translation task,
// sets its status=running and worker_id=workerID, and returns it.
// Returns (zero, false, nil) when the queue is empty.
ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error)
// FinishScrapeTask marks a running scrape task as done and records the result. // FinishScrapeTask marks a running scrape task as done and records the result.
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
// FinishAudioTask marks a running audio task as done and records the result. // FinishAudioTask marks a running audio task as done and records the result.
FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error
// FailTask marks a task (scrape or audio) as failed with an error message. // FinishTranslationTask marks a running translation task as done and records the result.
FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error
// FailTask marks a task (scrape, audio, or translation) as failed with an error message.
FailTask(ctx context.Context, id, errMsg string) error FailTask(ctx context.Context, id, errMsg string) error
// HeartbeatTask updates the heartbeat_at timestamp on a running task. // HeartbeatTask updates the heartbeat_at timestamp on a running task.
@@ -81,4 +93,11 @@ type Reader interface {
// GetAudioTask returns the most recent audio task for cacheKey. // GetAudioTask returns the most recent audio task for cacheKey.
// Returns (zero, false, nil) if not found. // Returns (zero, false, nil) if not found.
GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error) GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error)
// ListTranslationTasks returns all translation tasks sorted by started descending.
ListTranslationTasks(ctx context.Context) ([]domain.TranslationTask, error)
// GetTranslationTask returns the most recent translation task for cacheKey.
// Returns (zero, false, nil) if not found.
GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error)
} }

View File

@@ -23,6 +23,9 @@ func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) { func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "audio-1", nil return "audio-1", nil
} }
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "translation-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil } func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) { func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
@@ -31,12 +34,18 @@ func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.Scr
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) { func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil
} }
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error { func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
return nil return nil
} }
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error { func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
return nil return nil
} }
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
return nil
}
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil } func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil } func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
@@ -53,6 +62,12 @@ func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) { func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
return domain.AudioTask{}, false, nil return domain.AudioTask{}, false, nil
} }
func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.TranslationTask, error) {
return nil, nil
}
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
// Verify the stub satisfies all three interfaces at compile time. // Verify the stub satisfies all three interfaces at compile time.
var _ taskqueue.Producer = (*stubStore)(nil) var _ taskqueue.Producer = (*stubStore)(nil)

BIN
backend/runner Executable file

Binary file not shown.

View File

@@ -2,7 +2,8 @@ FROM caddy:2-builder AS builder
RUN xcaddy build \ RUN xcaddy build \
--with github.com/mholt/caddy-ratelimit \ --with github.com/mholt/caddy-ratelimit \
--with github.com/hslatman/caddy-crowdsec-bouncer/http --with github.com/hslatman/caddy-crowdsec-bouncer/http \
--with github.com/mholt/caddy-l4
FROM caddy:2-alpine FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy COPY --from=builder /usr/bin/caddy /usr/bin/caddy

139
caddy/errors/404.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — Page Not Found — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #71717a;
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #71717a;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<div class="watermark">404</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Page not found</span>
</div>
<h1>Nothing here</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a class="btn" href="/">Go home</a>
</main>
<footer>
&copy; LibNovel
</footer>
</body>
</html>

View File

@@ -3,49 +3,160 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>502 — Service Unavailable</title> <title>502 — Service Unavailable — LibNovel</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body { body {
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; padding: 3rem 2rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
text-align: center; text-align: center;
gap: 0;
} }
.code {
font-size: clamp(4rem, 20vw, 8rem); .watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800; font-weight: 800;
color: #27272a; color: #18181b;
line-height: 1; line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
} }
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; } .status-row {
a { display: flex;
margin-top: 0.5rem; align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block; display: inline-block;
padding: 0.6rem 1.4rem; padding: 0.625rem 1.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: #f59e0b; background: #f59e0b;
color: #000; color: #000;
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
} }
a:hover { background: #d97706; }
</style> </style>
</head> </head>
<body> <body>
<div class="code">502</div>
<h1>Service Unavailable</h1> <header>
<p>The server is temporarily unreachable. Please try again in a moment.</p> <a class="logo" href="/">Lib<span>Novel</span></a>
<a href="/">Go home</a> </header>
<main>
<div class="watermark">502</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Service unavailable</span>
</div>
<h1>Something went wrong</h1>
<p>The server is temporarily unreachable. This usually resolves itself quickly.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body> </body>
</html> </html>

View File

@@ -3,49 +3,163 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>503 — Maintenance</title> <title>Under Maintenance — LibNovel</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body { body {
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
/* ── Header ── */
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
/* ── Main ── */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; padding: 3rem 2rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
text-align: center; text-align: center;
gap: 0;
} }
.code {
font-size: clamp(4rem, 20vw, 8rem); .watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800; font-weight: 800;
color: #27272a; color: #18181b;
line-height: 1; line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
} }
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; } .status-row {
a { display: flex;
margin-top: 0.5rem; align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block; display: inline-block;
padding: 0.6rem 1.4rem; padding: 0.625rem 1.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: #f59e0b; background: #f59e0b;
color: #000; color: #000;
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
/* ── Footer ── */
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
} }
a:hover { background: #d97706; }
</style> </style>
</head> </head>
<body> <body>
<div class="code">503</div>
<h1>Under Maintenance</h1> <header>
<p>LibNovel is briefly offline for maintenance. We&rsquo;ll be back shortly.</p> <a class="logo" href="/">Lib<span>Novel</span></a>
<a href="/">Try again</a> </header>
<main>
<div class="watermark">503</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Maintenance in progress</span>
</div>
<h1>We'll be right back</h1>
<p>LibNovel is briefly offline for scheduled maintenance. No data is being changed — hang tight.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">30</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 30;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body> </body>
</html> </html>

View File

@@ -3,49 +3,160 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>504 — Gateway Timeout</title> <title>504 — Gateway Timeout — LibNovel</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body { body {
min-height: 100svh; min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; padding: 3rem 2rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
text-align: center; text-align: center;
gap: 0;
} }
.code {
font-size: clamp(4rem, 20vw, 8rem); .watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800; font-weight: 800;
color: #27272a; color: #18181b;
line-height: 1; line-height: 1;
letter-spacing: -0.04em; letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
} }
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; } .status-row {
a { display: flex;
margin-top: 0.5rem; align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.btn {
display: inline-block; display: inline-block;
padding: 0.6rem 1.4rem; padding: 0.625rem 1.5rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: #f59e0b; background: #f59e0b;
color: #000; color: #000;
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
text-decoration: none; text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
} }
a:hover { background: #d97706; }
</style> </style>
</head> </head>
<body> <body>
<div class="code">504</div>
<h1>Gateway Timeout</h1> <header>
<p>The request took too long to complete. Please refresh and try again.</p> <a class="logo" href="/">Lib<span>Novel</span></a>
<a href="/">Go home</a> </header>
<main>
<div class="watermark">504</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Gateway timeout</span>
</div>
<h1>Request timed out</h1>
<p>The server took too long to respond. Please refresh and try again.</p>
<a class="btn" href="/">Try again</a>
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body> </body>
</html> </html>

View File

@@ -15,7 +15,7 @@ x-infra-env: &infra-env
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}" POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}" POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# Meilisearch # Meilisearch
MEILI_URL: "http://meilisearch:7700" MEILI_URL: "${MEILI_URL:-http://meilisearch:7700}"
MEILI_API_KEY: "${MEILI_MASTER_KEY}" MEILI_API_KEY: "${MEILI_MASTER_KEY}"
# Valkey # Valkey
VALKEY_ADDR: "valkey:6379" VALKEY_ADDR: "valkey:6379"
@@ -160,9 +160,15 @@ services:
LOG_LEVEL: "${LOG_LEVEL}" LOG_LEVEL: "${LOG_LEVEL}"
KOKORO_URL: "${KOKORO_URL}" KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}" KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}" GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}" OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "backend" OTEL_SERVICE_NAME: "backend"
# Asynq task queue — backend enqueues jobs to homelab Redis via Caddy TLS proxy.
# Set to "rediss://:password@redis.libnovel.cc:6380" in Doppler prd config.
# Leave empty to fall back to PocketBase polling.
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
healthcheck: healthcheck:
test: ["CMD", "/healthcheck", "http://localhost:8080/health"] test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
interval: 15s interval: 15s
@@ -218,6 +224,7 @@ services:
# Kokoro-FastAPI TTS endpoint # Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL}" KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}" KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}" GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}" OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "runner" OTEL_SERVICE_NAME: "runner"
@@ -353,13 +360,16 @@ services:
# ─── Caddy (reverse proxy + automatic HTTPS) ────────────────────────────────── # ─── Caddy (reverse proxy + automatic HTTPS) ──────────────────────────────────
# Custom build includes github.com/mholt/caddy-ratelimit and # Custom build includes github.com/mholt/caddy-ratelimit,
# github.com/hslatman/caddy-crowdsec-bouncer/http. # github.com/hslatman/caddy-crowdsec-bouncer/http, and
# github.com/mholt/caddy-l4 (TCP layer4 proxy for Redis).
caddy: caddy:
image: kalekber/libnovel-caddy:${GIT_TAG:-latest} image: kalekber/libnovel-caddy:${GIT_TAG:-latest}
build: build:
context: ./caddy context: ./caddy
dockerfile: Dockerfile dockerfile: Dockerfile
labels:
com.centurylinklabs.watchtower.enable: "true"
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
backend: backend:
@@ -372,9 +382,12 @@ services:
- "80:80" - "80:80"
- "443:443" - "443:443"
- "443:443/udp" # HTTP/3 (QUIC) - "443:443/udp" # HTTP/3 (QUIC)
- "6380:6380" # Redis TCP proxy (TLS) for homelab → Asynq
environment: environment:
DOMAIN: "${DOMAIN}" DOMAIN: "${DOMAIN}"
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}" CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}"
# Homelab Redis address — Caddy TCP-proxies inbound :6380 to this.
HOMELAB_REDIS_ADDR: "${HOMELAB_REDIS_ADDR:?HOMELAB_REDIS_ADDR required for Redis TCP proxy}"
env_file: env_file:
- path: ./crowdsec/.crowdsec.env - path: ./crowdsec/.crowdsec.env
required: false required: false
@@ -388,15 +401,19 @@ services:
# ─── Watchtower (auto-redeploy custom services on new images) ──────────────── # ─── Watchtower (auto-redeploy custom services on new images) ────────────────
# Only watches services labelled com.centurylinklabs.watchtower.enable=true. # Only watches services labelled com.centurylinklabs.watchtower.enable=true.
# Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded. # Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded.
# doppler binary is mounted from the host so watchtower fetches fresh secrets
# on every start (notification URL, credentials) without baking them in.
watchtower: watchtower:
image: containrrr/watchtower:latest image: containrrr/watchtower:latest
restart: unless-stopped restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
command: --label-enable --interval 300 --cleanup - /usr/bin/doppler:/usr/bin/doppler:ro
- /root/.doppler:/root/.doppler:ro
environment: environment:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}" HOME: "/root"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
DOCKER_API_VERSION: "1.44" DOCKER_API_VERSION: "1.44"
volumes: volumes:

View File

@@ -35,11 +35,11 @@ client: Browser / iOS App {
caddy: Caddy :443 { caddy: Caddy :443 {
shape: rectangle shape: rectangle
style.fill: "#f1f5f9" style.fill: "#f1f5f9"
label: "Caddy :443\ncustom build · caddy-ratelimit\nsecurity headers · rate limiting\nstatic error pages" label: "Caddy :443\ncustom build · caddy-l4 · caddy-ratelimit\nCrowdSec bouncer · security headers\nrate limiting · static error pages\nRedis TCP proxy :6380"
} }
# ─── SvelteKit UI ───────────────────────────────────────────────────────────── # ─── SvelteKit UI ─────────────────────────────────────────────────────────────
# Handles: auth enforcement, session, all /api/* routes that have SK counterparts # All routes here pass through SvelteKit — auth is enforced server-side.
sk: SvelteKit UI :3000 { sk: SvelteKit UI :3000 {
style.fill: "#fef3c7" style.fill: "#fef3c7"
@@ -53,7 +53,7 @@ sk: SvelteKit UI :3000 {
catalogue_sk: Catalogue { catalogue_sk: Catalogue {
style.fill: "#f0fdf4" style.fill: "#f0fdf4"
style.stroke: "#86efac" style.stroke: "#86efac"
label: "GET /api/catalogue-page\nGET /api/search" label: "GET /api/catalogue-page (infinite scroll)\nGET /api/search"
} }
book_sk: Book { book_sk: Book {
@@ -65,7 +65,7 @@ sk: SvelteKit UI :3000 {
scrape_sk: Scrape (admin) { scrape_sk: Scrape (admin) {
style.fill: "#fff7ed" style.fill: "#fff7ed"
style.stroke: "#fdba74" style.stroke: "#fdba74"
label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/range\nPOST /api/scrape/cancel/{id}" label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/book\nPOST /api/scrape/book/range\nPOST /api/scrape/cancel/{id}"
} }
audio_sk: Audio { audio_sk: Audio {
@@ -74,7 +74,7 @@ sk: SvelteKit UI :3000 {
label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/voices" label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/voices"
} }
presign_sk: Presigned URLs { presign_sk: Presigned URLs (public) {
style.fill: "#f0fdf4" style.fill: "#f0fdf4"
style.stroke: "#86efac" style.stroke: "#86efac"
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}" label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}"
@@ -106,12 +106,12 @@ sk: SvelteKit UI :3000 {
} }
# ─── Go Backend ─────────────────────────────────────────────────────────────── # ─── Go Backend ───────────────────────────────────────────────────────────────
# Caddy proxies these paths directly — no SvelteKit auth layer # Caddy proxies these paths directly — bypasses SvelteKit entirely.
be: Backend API :8080 { be: Backend API :8080 {
style.fill: "#eef3ff" style.fill: "#eef3ff"
health_be: Health { health_be: Health / Version {
style.fill: "#f0fdf4" style.fill: "#f0fdf4"
style.stroke: "#86efac" style.stroke: "#86efac"
label: "GET /health\nGET /api/version" label: "GET /health\nGET /api/version"
@@ -126,7 +126,7 @@ be: Backend API :8080 {
catalogue_be: Catalogue { catalogue_be: Catalogue {
style.fill: "#f0fdf4" style.fill: "#f0fdf4"
style.stroke: "#86efac" style.stroke: "#86efac"
label: "GET /api/browse\nGET /api/catalogue\nGET /api/ranking\nGET /api/cover/{domain}/{slug}" label: "GET /api/catalogue (Meilisearch)\nGET /api/browse (legacy MinIO cache)\nGET /api/ranking\nGET /api/cover/{domain}/{slug}"
} }
book_be: Book / Chapter { book_be: Book / Chapter {
@@ -138,7 +138,13 @@ be: Backend API :8080 {
audio_be: Audio { audio_be: Audio {
style.fill: "#f0fdf4" style.fill: "#f0fdf4"
style.stroke: "#86efac" style.stroke: "#86efac"
label: "GET /api/audio-proxy/{slug}/{n}\nGET /api/voices" label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/audio-proxy/{slug}/{n}\nGET /api/voices"
}
presign_be: Presigned URLs {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}\nGET /api/presign/avatar-upload/{userId}\nGET /api/presign/avatar/{userId}"
} }
} }
@@ -149,19 +155,19 @@ storage: Storage {
pb: PocketBase :8090 { pb: PocketBase :8090 {
shape: cylinder shape: cylinder
label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache" label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache\nranking"
} }
mn: MinIO :9000 { mn: MinIO :9000 {
shape: cylinder shape: cylinder
label: "chapters · audio\navatars · browse" label: "chapters · audio\navatars · catalogue (browse)"
} }
ms: Meilisearch :7700 { ms: Meilisearch :7700 {
shape: cylinder shape: cylinder
label: "index: books" label: "index: books\nfilterable: status · genres\nsortable: rank · rating\n total_chapters · meta_updated"
} }
vk: Valkey :6379 { vk: Valkey :6379 {
shape: cylinder shape: cylinder
label: "presign URL cache" label: "presign URL cache (TTL ~55 min)\nAsynq job queue (runner)"
} }
} }
@@ -169,18 +175,17 @@ storage: Storage {
client -> caddy: HTTPS :443 client -> caddy: HTTPS :443
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth" caddy -> sk: "/* (catch-all)\n→ SvelteKit enforces auth"
caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking" caddy -> be: "/health /scrape*\n/api/browse /api/catalogue /api/ranking\n/api/version /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio* /api/voices /api/presign/*"
caddy -> storage.mn: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)" caddy -> storage.mn: "/avatars/* /audio/* /chapters/*\n(presigned MinIO GETs)"
# ─── SvelteKit → Backend (server-side proxy) ────────────────────────────────── # ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
sk.catalogue_sk -> be.catalogue_be: internal proxy sk.catalogue_sk -> be.catalogue_be: internal proxy
sk.book_sk -> be.book_be: internal proxy sk.book_sk -> be.book_be: internal proxy
sk.audio_sk -> be.audio_be: internal proxy sk.audio_sk -> be.audio_be: internal proxy
sk.presign_sk -> storage.vk: check cache sk.presign_sk -> be.presign_be: internal proxy
sk.presign_sk -> storage.mn: generate presign sk.presign_user -> be.presign_be: internal proxy
sk.presign_user -> storage.mn: generate presign
# ─── SvelteKit → Storage (direct) ──────────────────────────────────────────── # ─── SvelteKit → Storage (direct) ────────────────────────────────────────────
@@ -192,10 +197,12 @@ sk.comments_sk -> storage.pb
# ─── Backend → Storage ──────────────────────────────────────────────────────── # ─── Backend → Storage ────────────────────────────────────────────────────────
be.catalogue_be -> storage.ms: full-text search be.catalogue_be -> storage.ms: full-text search + facets
be.catalogue_be -> storage.pb: ranking records be.catalogue_be -> storage.pb: ranking records
be.catalogue_be -> storage.mn: cover presign be.catalogue_be -> storage.mn: cover presign
be.book_be -> storage.mn: chapter objects be.book_be -> storage.mn: chapter objects
be.book_be -> storage.pb: book metadata be.book_be -> storage.pb: book metadata
be.audio_be -> storage.mn: audio presign be.audio_be -> storage.mn: audio presign
be.audio_be -> storage.vk: presign cache be.audio_be -> storage.vk: presign cache
be.presign_be -> storage.vk: check / set presign cache
be.presign_be -> storage.mn: generate presigned URL

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -5,16 +5,25 @@ direction: right
novelfire: novelfire.net { novelfire: novelfire.net {
shape: cloud shape: cloud
style.fill: "#f0f4ff" style.fill: "#f0f4ff"
label: "novelfire.net\n(scrape source)"
} }
kokoro: Kokoro-FastAPI TTS { kokoro: Kokoro-FastAPI TTS {
shape: cloud shape: cloud
style.fill: "#f0f4ff" style.fill: "#f0f4ff"
label: "Kokoro-FastAPI TTS\n(self-hosted · homelab)\nchapter audio"
}
pockettts: pocket-tts {
shape: cloud
style.fill: "#f0f4ff"
label: "pocket-tts\n(self-hosted · homelab)\nvoice sample MP3s"
} }
letsencrypt: Let's Encrypt { letsencrypt: Let's Encrypt {
shape: cloud shape: cloud
style.fill: "#f0f4ff" style.fill: "#f0f4ff"
label: "Let's Encrypt\n(ACME TLS-ALPN-01)"
} }
browser: Browser / iOS App { browser: Browser / iOS App {
@@ -30,12 +39,12 @@ init: Init containers {
minio-init: minio-init { minio-init: minio-init {
shape: rectangle shape: rectangle
label: "minio-init\n(mc: create buckets)" label: "minio-init\n(mc: create buckets\n chapters · audio\n avatars · catalogue)"
} }
pb-init: pb-init { pb-init: pb-init {
shape: rectangle shape: rectangle
label: "pb-init\n(bootstrap collections)" label: "pb-init\n(bootstrap PocketBase\n collections + schema)"
} }
} }
@@ -46,109 +55,126 @@ storage: Storage {
minio: MinIO { minio: MinIO {
shape: cylinder shape: cylinder
label: "MinIO :9000\n\nbuckets:\n chapters\n audio\n avatars\n catalogue" label: "MinIO :9000\nbuckets:\n chapters · audio\n avatars · catalogue"
} }
pocketbase: PocketBase { pocketbase: PocketBase {
shape: cylinder shape: cylinder
label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking" label: "PocketBase :8090\ncollections:\n books · chapters_idx\n audio_cache · progress\n scrape_jobs · app_users\n ranking · library\n comments"
} }
valkey: Valkey { valkey: Valkey {
shape: cylinder shape: cylinder
label: "Valkey :6379\n\n(presign URL cache\nTTL-based, shared)" label: "Valkey :6379\npresign URL cache (TTL ~55 min)\nAsynq job queue (runner tasks)"
} }
meilisearch: Meilisearch { meilisearch: Meilisearch {
shape: cylinder shape: cylinder
label: "Meilisearch :7700\n\nindices:\n books" label: "Meilisearch :7700\nindex: books\n(filterable: status · genres\n sortable: rank · rating\n total_chapters · meta_updated)"
} }
} }
# ─── Application ────────────────────────────────────────────────────────────── # ─── Application — prod VPS (165.22.70.138) ───────────────────────────────────
app: Application { app: Application — prod (165.22.70.138) {
style.fill: "#eef3ff" style.fill: "#eef3ff"
caddy: caddy { caddy: caddy {
shape: rectangle shape: rectangle
label: "Caddy :443 / :80\ncustom build + caddy-ratelimit\n\nfeatures:\n auto-HTTPS (Let's Encrypt)\n security headers\n rate limiting (per-IP)\n static error pages (502/503/504)" label: "Caddy :443 / :80 / :6380\ncustom build\n+ caddy-l4 (Redis TCP proxy)\n+ caddy-ratelimit\nauto-HTTPS · security headers\nrate limiting (per-IP)\nstatic error pages (404/502/503/504)\nCrowdSec bouncer"
} }
backend: backend { backend: backend {
shape: rectangle shape: rectangle
label: "Backend API :8080\n(GoHTTP API server)" label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors"
}
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go — background worker\nscraping + TTS jobs\n/metrics endpoint)"
} }
ui: ui { ui: ui {
shape: rectangle shape: rectangle
label: "SvelteKit UI :3000\n(adapter-node)" label: "SvelteKit UI :3000\n(adapter-node)\nSSR · session auth\nserver-side API proxy"
}
crowdsec: CrowdSec {
shape: rectangle
label: "CrowdSec :8080\nsecurity engine\nreads Caddy JSON logs\nbouncer integrated in Caddy"
}
dozzle: Dozzle agent {
shape: rectangle
label: "Dozzle agent\n127.0.0.1:7007\nlog relay → homelab dashboard"
}
}
# ─── Runner — homelab (192.168.0.109) ────────────────────────────────────────
homelab: Runner — homelab (192.168.0.109) {
style.fill: "#fef9ec"
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go background worker)\nscrape pipeline\nTTS audio job queue\nPrometheus /metrics\ncron: catalogue refresh\nAsynq worker → Valkey"
} }
} }
# ─── Ops ────────────────────────────────────────────────────────────────────── # ─── Ops ──────────────────────────────────────────────────────────────────────
ops: Ops { ops: Ops {
style.fill: "#fef9ec" style.fill: "#f5f5f5"
watchtower: Watchtower { watchtower: Watchtower {
shape: rectangle shape: rectangle
label: "Watchtower\n(containrrr/watchtower)\n\npolls every 5 min\nautopulls + redeploys:\n backend · runner · ui" label: "Watchtower\n(containrrr/watchtower)\npolls Docker Hub every 5 min\nautopulls + redeploys:\n backend · ui\n(runner: label-disabled on prod)"
} }
} }
# ─── Init → Storage deps ────────────────────────────────────────────────────── # ─── CI / CD ──────────────────────────────────────────────────────────────────
cicd: CI / CD {
style.fill: "#f0f9ff"
gitea: Gitea Actions {
shape: rectangle
label: "Gitea Actions\n(homelab runner)\ntag v* trigger:\n test-backend\n check-ui (type-check + build)\n docker-backend\n docker-runner\n docker-ui (bakes releases.json)\n docker-caddy\n → push Docker Hub\n → Gitea Release"
}
}
# ─── Init → Storage ───────────────────────────────────────────────────────────
init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4} init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4} init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}
# ─── App → Storage ────────────────────────────────────────────────────────────
app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse)
app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…)
app.backend -> storage.valkey: cache presigned URLs\n(SET/GET with TTL)
app.runner -> storage.minio: write chapter markdown\n& audio MP3s
app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records
app.runner -> storage.meilisearch: index books on\nscrape completion
app.ui -> storage.valkey: read presigned URL cache
app.ui -> storage.pocketbase: auth, progress,\ncomments, settings
# ─── App internal ───────────────────────────────────────────────────────────── # ─── App internal ─────────────────────────────────────────────────────────────
app.ui -> app.backend: REST API calls (server-side)\n/api/catalogue /api/book-preview\n/api/chapter-text /api/audio etc. app.caddy -> app.ui: "/* (catch-all)\nSvelteKit — auth enforced"
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/catalogue\n/api/ranking /api/version\n/api/book-preview/*\n/api/chapter-text/*\n/api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/voices\n/api/audio* /api/presign/*"
app.caddy -> storage.minio: "/avatars/* /audio/*\n/chapters/*\n(presigned GETs)"
app.caddy -> app.crowdsec: bouncer check (15 s poll)
app.caddy -> letsencrypt: ACME cert (TLS-ALPN-01)
# ─── Caddy routing ──────────────────────────────────────────────────────────── app.ui -> app.backend: "internal REST proxy\n(server-side only)"
# Routes sent directly to backend (no SvelteKit counterpart): app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments"
# /health /scrape*
# /api/browse /api/book-preview/* /api/chapter-text/*
# /api/reindex/* /api/cover/* /api/audio-proxy/*
# Routes sent to MinIO:
# /avatars/*
# Everything else → SvelteKit UI (including /api/scrape/*, /api/chapter-text-preview/*)
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)" app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache"
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/*" app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking"
app.caddy -> storage.minio: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)" app.backend -> storage.valkey: "presign URL cache\n(SET/GET TTL ~55 min)"
app.backend -> storage.meilisearch: "catalogue search\nfacets: genres · status"
app.backend -> pockettts: "voice sample gen.\n(on-demand · ffmpeg conv.)"
# ─── External → App ─────────────────────────────────────────────────────────── # ─── Runner → deps ────────────────────────────────────────────────────────────
app.runner -> novelfire: scrape\n(HTTP GET) homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown"
app.runner -> kokoro: TTS generation\n(HTTP POST) homelab.runner -> kokoro: "TTS generation\ntext → MP3"
app.caddy -> letsencrypt: ACME certificate\n(TLS-ALPN-01) homelab.runner -> storage.minio: "write chapters\n& audio MP3s"
homelab.runner -> storage.pocketbase: "read/update scrape_jobs\nwrite book records"
homelab.runner -> storage.meilisearch: "index books\n(on scrape completion)"
homelab.runner -> storage.valkey: "Asynq job queue\n(task consume)"
# ─── Ops → Docker socket ────────────────────────────────────────────────────── # ─── Client ───────────────────────────────────────────────────────────────────
ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.runner: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
# ─── Browser ──────────────────────────────────────────────────────────────────
browser -> app.caddy: HTTPS :443\n(single entry point) browser -> app.caddy: HTTPS :443\n(single entry point)
# ─── Ops / CI ─────────────────────────────────────────────────────────────────
ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
cicd.gitea -> ops.watchtower: push to Docker Hub\n→ Watchtower detects new tag

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -221,7 +221,11 @@ services:
EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}" EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}" EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}" EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
EMAIL_SMTP_ENABLE_STARTTLS: "false" EMAIL_SMTP_ENABLE_STARTTLS: "${FIDER_SMTP_ENABLE_STARTTLS}"
OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}"
# ── Dozzle ────────────────────────────────────────────────────────────────── # ── Dozzle ──────────────────────────────────────────────────────────────────
# Watches both homelab and prod containers. # Watches both homelab and prod containers.
@@ -394,7 +398,7 @@ services:
# Voices match existing IDs: af_bella, af_sky, af_heart, etc. # Voices match existing IDs: af_bella, af_sky, af_heart, etc.
# The runner reaches it at http://kokoro-fastapi:8880 via the Docker network. # The runner reaches it at http://kokoro-fastapi:8880 via the Docker network.
kokoro-fastapi: kokoro-fastapi:
image: ghcr.io/remsky/kokoro-fastapi-gpu:latest image: kokoro-fastapi:latest
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
@@ -414,34 +418,44 @@ services:
# ── pocket-tts (CPU TTS) ──────────────────────────────────────────────────── # ── pocket-tts (CPU TTS) ────────────────────────────────────────────────────
# Lightweight CPU-only TTS using kyutai-labs/pocket-tts. # Lightweight CPU-only TTS using kyutai-labs/pocket-tts.
# OpenAI-compatible: POST /v1/audio/speech on port 8000. # Image is built locally on homelab from https://github.com/kyutai-labs/pocket-tts
# Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma. # (no prebuilt image published): cd /tmp && git clone --depth=1 https://github.com/kyutai-labs/pocket-tts.git && docker build -t pocket-tts:latest /tmp/pocket-tts
# OpenAI-compatible: POST /tts (multipart form) on port 8000.
# Voices: alba, marius, javert, jean, fantine, cosette, eponine, azelma, etc.
# Not currently used by the runner (runner uses kokoro-fastapi), but available # Not currently used by the runner (runner uses kokoro-fastapi), but available
# for experimentation / fallback. # for experimentation / fallback.
pocket-tts: pocket-tts:
image: ghcr.io/kyutai-labs/pocket-tts:latest image: pocket-tts:latest
restart: unless-stopped restart: unless-stopped
command: ["uv", "run", "pocket-tts", "serve", "--host", "0.0.0.0"]
expose: expose:
- "8000" - "8000"
volumes:
- pocket_tts_cache:/root/.cache/pocket_tts
- hf_cache:/root/.cache/huggingface
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8000/health"] test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 60s start_period: 120s
# ── Watchtower ────────────────────────────────────────────────────────────── # ── Watchtower ──────────────────────────────────────────────────────────────
# Auto-updates runner image when CI pushes a new tag. # Auto-updates runner image when CI pushes a new tag.
# Only watches services with the watchtower label. # Only watches services with the watchtower label.
# doppler binary is mounted from the host so watchtower fetches fresh secrets
# on every start (notification URL, credentials) without baking them in.
watchtower: watchtower:
image: containrrr/watchtower:latest image: containrrr/watchtower:latest
restart: unless-stopped restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd_homelab", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
command: --label-enable --interval 300 --cleanup - /usr/bin/doppler:/usr/bin/doppler:ro
- /root/.doppler:/root/.doppler:ro
environment: environment:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}" HOME: "/root"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
DOCKER_API_VERSION: "1.44" DOCKER_API_VERSION: "1.44"
volumes: volumes:
@@ -453,3 +467,5 @@ volumes:
prometheus_data: prometheus_data:
loki_data: loki_data:
grafana_data: grafana_data:
pocket_tts_cache:
hf_cache:

View File

@@ -0,0 +1,16 @@
# Grafana alerting provisioning — contact points
# Sends all alerts to Gotify (self-hosted push notifications).
apiVersion: 1
contactPoints:
- orgId: 1
name: Gotify
receivers:
- uid: gotify-webhook
type: webhook
settings:
url: "http://gotify/message?token=ABZrZgCY-4ivcmt"
httpMethod: POST
title: "{{ .CommonLabels.alertname }}"
message: "{{ range .Alerts }}{{ .Annotations.summary }}\n{{ .Annotations.description }}{{ end }}"
disableResolveMessage: false

View File

@@ -0,0 +1,15 @@
# Grafana alerting provisioning — notification policies
# Routes all alerts to Gotify by default.
apiVersion: 1
policies:
- orgId: 1
receiver: Gotify
group_by: ["alertname", "service"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- receiver: Gotify
matchers:
- severity =~ "critical|warning"

View File

@@ -0,0 +1,214 @@
# Grafana alerting provisioning — alert rules
# Covers: runner down, high task failure rate, audio error spike, backend error spike.
apiVersion: 1
groups:
- orgId: 1
name: LibNovel Runner
folder: LibNovel
interval: 1m
rules:
- uid: runner-down
title: Runner Down
condition: C
for: 2m
annotations:
summary: "LibNovel runner is not reachable"
description: "The Prometheus scrape of runner:9091 has been failing for >2 minutes. Tasks are not being processed."
labels:
severity: critical
service: runner
data:
- refId: A
datasourceUid: prometheus
relativeTimeRange: { from: 300, to: 0 }
model:
expr: "up{job=\"libnovel-runner\"}"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 300, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [1], type: lt }
operator: { type: and }
query: { params: [A] }
reducer: { params: [], type: last }
- uid: runner-high-failure-rate
title: Runner High Task Failure Rate
condition: C
for: 5m
annotations:
summary: "Runner task failure rate is above 20%"
description: "More than 20% of runner tasks have been failing for the last 5 minutes. Check runner logs."
labels:
severity: warning
service: runner
data:
- refId: A
datasourceUid: prometheus
relativeTimeRange: { from: 600, to: 0 }
model:
expr: "rate(libnovel_runner_tasks_failed_total[5m]) / clamp_min(rate(libnovel_runner_tasks_completed_total[5m]) + rate(libnovel_runner_tasks_failed_total[5m]), 0.001)"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 600, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [0.2], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { params: [], type: last }
- uid: runner-tasks-stalled
title: Runner Tasks Stalled
condition: C
for: 10m
annotations:
summary: "Runner has tasks running for >10 minutes with no completions"
description: "tasks_running > 0 but rate(tasks_completed) is 0. Tasks may be stuck or the runner is in a crash loop."
labels:
severity: warning
service: runner
data:
- refId: Running
datasourceUid: prometheus
relativeTimeRange: { from: 900, to: 0 }
model:
expr: "libnovel_runner_tasks_running"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: Rate
datasourceUid: prometheus
relativeTimeRange: { from: 900, to: 0 }
model:
expr: "rate(libnovel_runner_tasks_completed_total[10m])"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 900, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [0], type: gt }
operator: { type: and }
query: { params: [Running] }
reducer: { params: [], type: last }
- evaluator: { params: [0.001], type: lt }
operator: { type: and }
query: { params: [Rate] }
reducer: { params: [], type: last }
- orgId: 1
name: LibNovel Backend
folder: LibNovel
interval: 1m
rules:
- uid: backend-high-error-rate
title: Backend High Error Rate
condition: C
for: 5m
annotations:
summary: "Backend API error rate above 5%"
description: "More than 5% of backend HTTP requests are returning 5xx status codes (as seen from UI OTel instrumentation)."
labels:
severity: warning
service: backend
data:
- refId: A
datasourceUid: prometheus
relativeTimeRange: { from: 600, to: 0 }
model:
expr: "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) / clamp_min(sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])), 0.001)"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 600, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [0.05], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { params: [], type: last }
- uid: backend-high-p95-latency
title: Backend High p95 Latency
condition: C
for: 5m
annotations:
summary: "Backend p95 latency above 2s"
description: "95th percentile latency of backend spans has exceeded 2 seconds for >5 minutes."
labels:
severity: warning
service: backend
data:
- refId: A
datasourceUid: prometheus
relativeTimeRange: { from: 600, to: 0 }
model:
expr: "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 600, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [2], type: gt }
operator: { type: and }
query: { params: [A] }
reducer: { params: [], type: last }
- orgId: 1
name: LibNovel OTel Pipeline
folder: LibNovel
interval: 2m
rules:
- uid: otel-collector-down
title: OTel Collector Down
condition: C
for: 3m
annotations:
summary: "OTel collector is not reachable"
description: "Prometheus cannot scrape otel-collector:8888. Traces and logs may be dropping."
labels:
severity: warning
service: otel-collector
data:
- refId: A
datasourceUid: prometheus
relativeTimeRange: { from: 600, to: 0 }
model:
expr: "up{job=\"otel-collector\"}"
instant: true
intervalMs: 1000
maxDataPoints: 43200
- refId: C
datasourceUid: __expr__
relativeTimeRange: { from: 600, to: 0 }
model:
type: classic_conditions
conditions:
- evaluator: { params: [1], type: lt }
operator: { type: and }
query: { params: [A] }
reducer: { params: [], type: last }

View File

@@ -0,0 +1,338 @@
{
"uid": "libnovel-backend",
"title": "Backend API",
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.",
"tags": ["libnovel", "backend", "api"],
"timezone": "browser",
"refresh": "30s",
"time": { "from": "now-3h", "to": "now" },
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "Request Rate (RPS)",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))",
"legendFormat": "rps",
"instant": true
}
]
},
{
"id": 2,
"type": "stat",
"title": "Error Rate",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none"
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) / clamp_min(sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])), 0.001)",
"legendFormat": "error rate",
"instant": true
}
]
},
{
"id": 3,
"type": "stat",
"title": "p50 Latency",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.2 },
{ "color": "red", "value": 1 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p50",
"instant": true
}
]
},
{
"id": 4,
"type": "stat",
"title": "p95 Latency",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p95",
"instant": true
}
]
},
{
"id": 5,
"type": "stat",
"title": "p99 Latency",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.99, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p99",
"instant": true
}
]
},
{
"id": 6,
"type": "stat",
"title": "5xx Errors / min",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60",
"legendFormat": "5xx/min",
"instant": true
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Request Rate by Status",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"targets": [
{
"refId": "success",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))",
"legendFormat": "2xx"
},
{
"refId": "notfound",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))",
"legendFormat": "4xx"
},
{
"refId": "errors",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))",
"legendFormat": "5xx"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "Latency Percentiles (backend spans)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "s", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p95"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.99, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le))",
"legendFormat": "p99"
}
]
},
{
"id": 12,
"type": "timeseries",
"title": "Requests / min by HTTP method (UI → Backend)",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60",
"legendFormat": "{{http_request_method}}"
}
]
},
{
"id": 13,
"type": "timeseries",
"title": "Requests / min — UI → PocketBase",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60",
"legendFormat": "{{http_request_method}} {{http_response_status_code}}"
}
]
},
{
"id": 14,
"type": "timeseries",
"title": "UI → Backend Latency (p50 / p95)",
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "s", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p95"
}
]
},
{
"id": 20,
"type": "logs",
"title": "Backend Errors",
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"backend\"} | json | level =~ `(WARN|ERROR|error|warn)`",
"legendFormat": ""
}
]
}
]
}

View File

@@ -0,0 +1,275 @@
{
"uid": "libnovel-catalogue",
"title": "Catalogue & Content Progress",
"description": "Scraping progress, audio generation coverage, and catalogue health derived from runner structured logs.",
"tags": ["libnovel", "catalogue", "content"],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "Books Scraped (last 24h)",
"description": "Count of unique book slugs appearing in successful scrape task completions.",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task done` [24h])",
"legendFormat": "books scraped"
}
]
},
{
"id": 2,
"type": "stat",
"title": "Chapters Scraped (last 24h)",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | unwrap scraped [24h])",
"legendFormat": "chapters scraped"
}
]
},
{
"id": 3,
"type": "stat",
"title": "Audio Jobs Completed (last 24h)",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "green", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task done` [24h])",
"legendFormat": "audio done"
}
]
},
{
"id": 4,
"type": "stat",
"title": "Audio Jobs Failed (last 24h)",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task failed` [24h])",
"legendFormat": "audio failed"
}
]
},
{
"id": 5,
"type": "stat",
"title": "Scrape Errors (last 24h)",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
}
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task failed` [24h])",
"legendFormat": "scrape errors"
}
]
},
{
"id": 6,
"type": "stat",
"title": "Catalogue Refresh — Books Indexed",
"description": "Total books indexed in the last catalogue refresh cycle (from the ok field in the summary log).",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "purple", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "last_over_time({service_name=\"runner\"} | json | op=`catalogue_refresh` | msg=`catalogue refresh done` | unwrap ok [7d])",
"legendFormat": "indexed"
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Audio Generation Rate (tasks/min)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"description": "Rate of audio task completions and failures over time.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "completed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "failed"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "Scraping Rate (tasks/min)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"description": "Rate of scrape task completions and failures over time.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "completed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "failed"
}
]
},
{
"id": 20,
"type": "logs",
"title": "Scrape Task Events",
"description": "One log line per completed or failed scrape task. Fields: task_id, kind, url, scraped, skipped, errors.",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | msg =~ `scrape task (done|failed|starting)`",
"legendFormat": ""
}
]
},
{
"id": 21,
"type": "logs",
"title": "Audio Task Events",
"description": "One log line per completed or failed audio task. Fields: task_id, slug, chapter, voice, key (on success), reason (on failure).",
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | msg =~ `audio task (done|failed|starting)`",
"legendFormat": ""
}
]
},
{
"id": 22,
"type": "logs",
"title": "Catalogue Refresh Progress",
"description": "Progress logs from the background catalogue refresh (every 24h). Fields: op=catalogue_refresh, scraped, ok, skipped, errors.",
"gridPos": { "x": 0, "y": 32, "w": 24, "h": 8 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": false,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | op=`catalogue_refresh`",
"legendFormat": ""
}
]
}
]
}

View File

@@ -0,0 +1,377 @@
{
"uid": "libnovel-runner",
"title": "Runner Operations",
"description": "Task queue health, throughput, TTS routing, and live logs for the homelab runner.",
"tags": ["libnovel", "runner"],
"timezone": "browser",
"refresh": "30s",
"time": { "from": "now-3h", "to": "now" },
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "Tasks Running",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 3 }
]
},
"mappings": []
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"legendFormat": "running",
"instant": true
}
]
},
{
"id": 2,
"type": "stat",
"title": "Tasks Completed (total)",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "area",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "green", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_completed_total",
"legendFormat": "completed",
"instant": true
}
]
},
{
"id": 3,
"type": "stat",
"title": "Tasks Failed (total)",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total",
"legendFormat": "failed",
"instant": true
}
]
},
{
"id": 4,
"type": "stat",
"title": "Runner Uptime",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "value",
"graphMode": "none",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 60 },
{ "color": "green", "value": 300 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_uptime_seconds",
"legendFormat": "uptime",
"instant": true
}
]
},
{
"id": 5,
"type": "stat",
"title": "Task Failure Rate",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.05 },
{ "color": "red", "value": 0.2 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)",
"legendFormat": "failure rate",
"instant": true
}
]
},
{
"id": 6,
"type": "stat",
"title": "Runner Alive",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"colorMode": "background",
"graphMode": "none",
"textMode": "auto"
},
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" }, "0": { "text": "DOWN", "color": "red" } } }
],
"thresholds": { "mode": "absolute", "steps": [] }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "up{job=\"libnovel-runner\"}",
"legendFormat": "runner",
"instant": true
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "Task Throughput (per minute)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": { "lineWidth": 2, "fillOpacity": 10 }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60",
"legendFormat": "completed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60",
"legendFormat": "failed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"legendFormat": "running"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "Audio Task Span Latency (p50 / p95 / p99)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"description": "End-to-end latency of runner.audio_task spans from Tempo span metrics.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.audio_task\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.audio_task\"}[5m])) by (le))",
"legendFormat": "p95"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.99, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.audio_task\"}[5m])) by (le))",
"legendFormat": "p99"
}
]
},
{
"id": 20,
"type": "timeseries",
"title": "Scrape Task Span Latency (p50 / p95 / p99)",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"description": "End-to-end latency of runner.scrape_task spans from Tempo span metrics.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.scrape_task\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.scrape_task\"}[5m])) by (le))",
"legendFormat": "p95"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.99, sum(rate(traces_spanmetrics_latency_bucket{service=\"runner\", span_name=\"runner.scrape_task\"}[5m])) by (le))",
"legendFormat": "p99"
}
]
},
{
"id": 21,
"type": "timeseries",
"title": "Audio vs Scrape Task Rate",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"description": "Relative throughput of audio generation vs book scraping.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"unit": "ops",
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\"}[5m]))",
"legendFormat": "audio tasks/s"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\"}[5m]))",
"legendFormat": "scrape tasks/s"
}
]
},
{
"id": 30,
"type": "logs",
"title": "Runner Logs (errors & warnings)",
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"showCommonLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json | level =~ `(WARN|ERROR|error|warn)`",
"legendFormat": ""
}
]
},
{
"id": 31,
"type": "logs",
"title": "Runner Logs (all)",
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"showCommonLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"runner\"} | json",
"legendFormat": ""
}
]
}
]
}

View File

@@ -1,7 +1,7 @@
# LibNovel homelab runner # LibNovel homelab runner
# #
# Connects to production PocketBase and MinIO via public subdomains. # Connects to production PocketBase and MinIO via public subdomains.
# All secrets come from Doppler (project=libnovel, config=prd). # All secrets come from Doppler (project=libnovel, config=prd_homelab).
# Run with: doppler run -- docker compose up -d # Run with: doppler run -- docker compose up -d
# #
# Differs from prod runner: # Differs from prod runner:
@@ -11,12 +11,54 @@
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied) # - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly) # - VALKEY_ADDR → unset (not exposed publicly)
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true # - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - Redis service for Asynq task queue (local to homelab, exposed to prod via Caddy TCP proxy)
# - LibreTranslate service for machine translation (internal network only)
services: services:
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
command: >
redis-server
--appendonly yes
--requirepass "${REDIS_PASSWORD}"
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
# Limit to source→target pairs the runner actually uses
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
runner: runner:
image: kalekber/libnovel-runner:latest image: kalekber/libnovel-runner:latest
restart: unless-stopped restart: unless-stopped
stop_grace_period: 135s stop_grace_period: 135s
depends_on:
redis:
condition: service_healthy
libretranslate:
condition: service_healthy
environment: environment:
# ── PocketBase ────────────────────────────────────────────────────────── # ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc" POCKETBASE_URL: "https://pb.libnovel.cc"
@@ -42,11 +84,24 @@ services:
KOKORO_URL: "${KOKORO_URL}" KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}" KOKORO_VOICE: "${KOKORO_VOICE}"
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (local service) ───────────────────────────────────────
# The runner connects to the local Redis sidecar.
REDIS_ADDR: "redis:6379"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
# ── Runner tuning ─────────────────────────────────────────────────────── # ── Runner tuning ───────────────────────────────────────────────────────
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}" RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}" RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}" RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}" RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}" RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}" RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true" RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
@@ -60,3 +115,8 @@ services:
interval: 60s interval: 60s
timeout: 5s timeout: 5s
retries: 3 retries: 3
volumes:
redis_data:
libretranslate_models:
libretranslate_db:

View File

@@ -245,6 +245,20 @@ create "comment_votes" '{
{"name":"vote", "type":"text"} {"name":"vote", "type":"text"}
]}' ]}'
create "translation_jobs" '{
"name":"translation_jobs","type":"base","fields":[
{"name":"cache_key", "type":"text", "required":true},
{"name":"slug", "type":"text", "required":true},
{"name":"chapter", "type":"number","required":true},
{"name":"lang", "type":"text", "required":true},
{"name":"worker_id", "type":"text"},
{"name":"status", "type":"text", "required":true},
{"name":"error_message","type":"text"},
{"name":"started", "type":"date"},
{"name":"finished", "type":"date"},
{"name":"heartbeat_at", "type":"date"}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─ # ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date" add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date" add_field "audio_jobs" "heartbeat_at" "date"
@@ -258,5 +272,7 @@ add_field "app_users" "verification_token" "text"
add_field "app_users" "verification_token_exp" "text" add_field "app_users" "verification_token_exp" "text"
add_field "app_users" "oauth_provider" "text" add_field "app_users" "oauth_provider" "text"
add_field "app_users" "oauth_id" "text" add_field "app_users" "oauth_id" "text"
add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
log "done" log "done"

3
ui/.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Generated by CI at build time — do not commit
/static/releases.json

View File

@@ -14,10 +14,12 @@ COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg. # Build-time version info — injected by docker-compose or CI via --build-arg.
ARG BUILD_VERSION=dev ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them. # Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build RUN npm run build
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
# Carry build-time metadata into the runtime image so the UI footer can
# display the version, commit SHA, and build timestamp.
# These must be re-declared after the second FROM — ARG values do not
# cross stage boundaries, but ENV values set here persist at runtime.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
EXPOSE $PORT EXPOSE $PORT
CMD ["node", "build"] CMD ["node", "build"]

410
ui/messages/en.json Normal file
View File

@@ -0,0 +1,410 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Library",
"nav_catalogue": "Catalogue",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Profile",
"nav_sign_in": "Sign in",
"nav_sign_out": "Sign out",
"nav_toggle_menu": "Toggle menu",
"nav_admin_panel": "Admin panel",
"footer_library": "Library",
"footer_catalogue": "Catalogue",
"footer_feedback": "Feedback",
"footer_disclaimer": "Disclaimer",
"footer_privacy": "Privacy",
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Books",
"home_stat_chapters": "Chapters",
"home_stat_in_progress": "In progress",
"home_continue_reading": "Continue Reading",
"home_view_all": "View all",
"home_recently_updated": "Recently Updated",
"home_from_following": "From People You Follow",
"home_empty_title": "Your library is empty",
"home_empty_body": "Discover novels and scrape them into your library.",
"home_discover_novels": "Discover Novels",
"home_via_reader": "via {username}",
"home_chapter_badge": "ch.{n}",
"player_generating": "Generating… {percent}%",
"player_loading": "Loading…",
"player_chapters": "Chapters",
"player_chapter_n": "Chapter {n}",
"player_toggle_chapter_list": "Toggle chapter list",
"player_chapter_list_label": "Chapter list",
"player_close_chapter_list": "Close chapter list",
"player_rewind_15": "Rewind 15 seconds",
"player_skip_30": "Skip 30 seconds",
"player_back_15": "Back 15s",
"player_forward_30": "Forward 30s",
"player_play": "Play",
"player_pause": "Pause",
"player_speed_label": "Playback speed {speed}x",
"player_seek_label": "Chapter progress",
"player_change_speed": "Change playback speed",
"player_auto_next_on": "Auto-next on",
"player_auto_next_off": "Auto-next off",
"player_auto_next_ready": "Auto-next on — Ch.{n} ready",
"player_auto_next_preparing": "Auto-next on — preparing Ch.{n}…",
"player_auto_next_aria": "Auto-next {state}",
"player_go_to_chapter": "Go to chapter",
"player_close": "Close player",
"login_page_title": "Sign in — libnovel",
"login_heading": "Sign in to libnovel",
"login_subheading": "Choose a provider to continue",
"login_continue_google": "Continue with Google",
"login_continue_github": "Continue with GitHub",
"login_terms_notice": "By signing in you agree to our terms of service.",
"login_error_oauth_state": "Sign-in was cancelled or expired. Please try again.",
"login_error_oauth_failed": "Could not connect to the provider. Please try again.",
"login_error_oauth_no_email": "Your account has no verified email address. Please add one and retry.",
"books_page_title": "Library — libnovel",
"books_heading": "Your Library",
"books_empty_title": "No books yet",
"books_empty_body": "Add books to your library by visiting a book page.",
"books_browse_catalogue": "Browse Catalogue",
"books_chapter_count": "{n} chapters",
"books_last_read": "Last read: Ch.{n}",
"books_reading_progress": "Ch.{current} / {total}",
"books_remove": "Remove",
"catalogue_page_title": "Catalogue — libnovel",
"catalogue_heading": "Catalogue",
"catalogue_search_placeholder": "Search novels…",
"catalogue_filter_genre": "Genre",
"catalogue_filter_status": "Status",
"catalogue_filter_sort": "Sort",
"catalogue_sort_popular": "Popular",
"catalogue_sort_new": "New",
"catalogue_sort_top_rated": "Top Rated",
"catalogue_sort_rank": "Rank",
"catalogue_status_all": "All",
"catalogue_status_ongoing": "Ongoing",
"catalogue_status_completed": "Completed",
"catalogue_genre_all": "All genres",
"catalogue_clear_filters": "Clear",
"catalogue_reset": "Reset",
"catalogue_no_results": "No novels found.",
"catalogue_loading": "Loading…",
"catalogue_load_more": "Load more",
"catalogue_results_count": "{n} results",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Sign in to save",
"book_detail_add_to_library": "Add to Library",
"book_detail_remove_from_library": "Remove from Library",
"book_detail_read_now": "Read Now",
"book_detail_continue_reading": "Continue Reading",
"book_detail_start_reading": "Start Reading",
"book_detail_chapters": "{n} Chapters",
"book_detail_status": "Status",
"book_detail_author": "Author",
"book_detail_genres": "Genres",
"book_detail_description": "Description",
"book_detail_source": "Source",
"book_detail_rescrape": "Re-scrape",
"book_detail_scraping": "Scraping…",
"book_detail_in_library": "In Library",
"chapters_page_title": "Chapters — {title}",
"chapters_heading": "Chapters",
"chapters_back_to_book": "Back to book",
"chapters_reading_now": "Reading",
"chapters_empty": "No chapters scraped yet.",
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Play narration",
"reader_generating_audio": "Generating audio…",
"reader_signin_for_audio": "Audio narration available",
"reader_signin_audio_desc": "Sign in to listen to this chapter narrated by AI.",
"reader_audio_error": "Audio generation failed.",
"reader_prev_chapter": "Previous chapter",
"reader_next_chapter": "Next chapter",
"reader_back_to_chapters": "Back to chapters",
"reader_chapter_n": "Chapter {n}",
"reader_change_voice": "Change voice",
"reader_voice_panel_title": "Select voice",
"reader_voice_kokoro": "Kokoro voices",
"reader_voice_pocket": "Pocket-TTS voices",
"reader_voice_play_sample": "Play sample",
"reader_voice_stop_sample": "Stop sample",
"reader_voice_selected": "Selected",
"reader_close_voice_panel": "Close voice panel",
"reader_auto_next": "Auto-next",
"reader_speed": "Speed",
"reader_preview_notice": "Preview — this chapter has not been fully scraped.",
"profile_page_title": "Profile — libnovel",
"profile_heading": "Profile",
"profile_avatar_label": "Avatar",
"profile_change_avatar": "Change avatar",
"profile_username": "Username",
"profile_email": "Email",
"profile_change_password": "Change password",
"profile_current_password": "Current password",
"profile_new_password": "New password",
"profile_confirm_password": "Confirm password",
"profile_save_password": "Save password",
"profile_appearance_heading": "Appearance",
"profile_theme_label": "Theme",
"profile_theme_amber": "Amber",
"profile_theme_slate": "Slate",
"profile_theme_rose": "Rose",
"profile_reading_heading": "Reading settings",
"profile_voice_label": "Default voice",
"profile_speed_label": "Playback speed",
"profile_auto_next_label": "Auto-next chapter",
"profile_save_settings": "Save settings",
"profile_settings_saved": "Settings saved.",
"profile_settings_error": "Failed to save settings.",
"profile_password_saved": "Password changed.",
"profile_password_error": "Failed to change password.",
"profile_sessions_heading": "Active sessions",
"profile_sign_out_all": "Sign out all other devices",
"profile_joined": "Joined {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "{username}'s Library",
"user_follow": "Follow",
"user_unfollow": "Unfollow",
"user_followers": "{n} followers",
"user_following": "{n} following",
"user_library_empty": "No books in library.",
"error_not_found_title": "Page not found",
"error_not_found_body": "The page you're looking for doesn't exist.",
"error_generic_title": "Something went wrong",
"error_go_home": "Go home",
"error_status": "Error {status}",
"admin_scrape_page_title": "Scrape — Admin",
"admin_scrape_heading": "Scrape",
"admin_scrape_catalogue": "Scrape Catalogue",
"admin_scrape_book": "Scrape Book",
"admin_scrape_url_placeholder": "novelfire.net book URL",
"admin_scrape_range": "Chapter range",
"admin_scrape_from": "From",
"admin_scrape_to": "To",
"admin_scrape_submit": "Scrape",
"admin_scrape_cancel": "Cancel",
"admin_scrape_status_pending": "Pending",
"admin_scrape_status_running": "Running",
"admin_scrape_status_done": "Done",
"admin_scrape_status_failed": "Failed",
"admin_scrape_status_cancelled": "Cancelled",
"admin_tasks_heading": "Recent tasks",
"admin_tasks_empty": "No tasks yet.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Audio Jobs",
"admin_audio_empty": "No audio jobs.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Comments",
"comments_empty": "No comments yet. Be the first!",
"comments_placeholder": "Write a comment…",
"comments_submit": "Post",
"comments_login_prompt": "Sign in to comment.",
"comments_vote_up": "Upvote",
"comments_vote_down": "Downvote",
"comments_delete": "Delete",
"comments_reply": "Reply",
"comments_show_replies": "Show {n} replies",
"comments_hide_replies": "Hide replies",
"comments_edited": "edited",
"comments_deleted": "[deleted]",
"disclaimer_page_title": "Disclaimer — libnovel",
"privacy_page_title": "Privacy Policy — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Terms of Service — libnovel",
"common_loading": "Loading…",
"common_error": "Error",
"common_save": "Save",
"common_cancel": "Cancel",
"common_close": "Close",
"common_search": "Search",
"common_back": "Back",
"common_next": "Next",
"common_previous": "Previous",
"common_yes": "Yes",
"common_no": "No",
"common_on": "on",
"common_off": "off",
"locale_switcher_label": "Language",
"books_empty_library": "Your library is empty.",
"books_empty_discover": "Books you start reading or save from",
"books_empty_discover_link": "Discover",
"books_empty_discover_suffix": "will appear here.",
"books_count": "{n} book{s}",
"catalogue_sort_updated": "Updated",
"catalogue_search_button": "Search",
"catalogue_refresh": "Refresh",
"catalogue_refreshing": "Queuing\u2026",
"catalogue_refresh_mobile": "Refresh catalogue",
"catalogue_all_loaded": "All novels loaded",
"catalogue_scroll_top": "Back to top",
"catalogue_view_grid": "Grid view",
"catalogue_view_list": "List view",
"catalogue_browse_source": "Browse novels from novelfire.net",
"catalogue_search_results": "{n} result{s} for \"{q}\"",
"catalogue_search_local_count": "({local} local, {remote} from novelfire)",
"catalogue_rank_ranked": "{n} novels ranked from last catalogue scrape",
"catalogue_rank_no_data": "No ranking data.",
"catalogue_rank_no_data_body": "No ranking data \u2014 run a full catalogue scrape to populate",
"catalogue_rank_run_scrape_admin": "Click Refresh catalogue above to trigger a full catalogue scrape.",
"catalogue_rank_run_scrape_user": "Ask an admin to run a catalogue scrape.",
"catalogue_scrape_queued_flash": "Full catalogue scrape queued. Library and ranking will update as books are processed.",
"catalogue_scrape_busy_flash": "A scrape job is already running. Check back once it finishes.",
"catalogue_scrape_error_flash": "Failed to queue scrape. Check that the scraper service is reachable.",
"catalogue_filters_label": "Filters",
"catalogue_apply": "Apply",
"catalogue_filter_rank_note": "Genre & status filters apply to Browse only",
"catalogue_no_results_search": "No results found.",
"catalogue_no_results_try": "Try a different search term.",
"catalogue_no_results_filters": "Try different filters or check back later.",
"catalogue_scrape_queued_badge": "Queued",
"catalogue_scrape_busy_badge": "Scraper busy",
"catalogue_scrape_busy_list": "Busy",
"catalogue_scrape_forbidden_badge": "Forbidden",
"catalogue_scrape_novel_button": "Scrape",
"catalogue_scraping_novel": "Scraping\u2026",
"book_detail_not_in_library": "not in library",
"book_detail_continue_ch": "Continue ch.{n}",
"book_detail_start_ch1": "Start from ch.1",
"book_detail_preview_ch1": "Preview ch.1",
"book_detail_reading_ch": "Reading ch.{n} of {total}",
"book_detail_n_chapters": "{n} chapters",
"book_detail_rescraping": "Queuing\u2026",
"book_detail_from_chapter": "From chapter",
"book_detail_to_chapter": "To chapter (optional)",
"book_detail_range_queuing": "Queuing\u2026",
"book_detail_scrape_range": "Scrape range",
"book_detail_admin": "Admin",
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
"book_detail_scraping_home": "\u2190 Home",
"book_detail_rescrape_book": "Rescrape book",
"book_detail_less": "Less",
"book_detail_more": "More",
"chapters_search_placeholder": "Search chapters\u2026",
"chapters_jump_to": "Jump to Ch.{n}",
"chapters_no_match": "No chapters match \"{q}\"",
"chapters_none_available": "No chapters available yet.",
"chapters_reading_indicator": "reading",
"chapters_result_count": "{n} results",
"reader_fetching_chapter": "Fetching chapter\u2026",
"reader_words": "{n} words",
"reader_preview_audio_notice": "Preview chapter \u2014 audio not available for books outside the library.",
"profile_click_to_change": "Click avatar to change photo",
"profile_tts_voice": "TTS voice",
"profile_auto_advance": "Auto-advance to next chapter",
"profile_saving": "Saving\u2026",
"profile_saved": "Saved!",
"profile_session_this": "This session",
"profile_session_signed_in": "Signed in {date}",
"profile_session_last_seen": "\u00b7 Last seen {date}",
"profile_session_sign_out": "Sign out",
"profile_session_end": "End",
"profile_session_unrecognised": "These are all devices currently signed into your account. End any session you don\u2019t recognise.",
"profile_no_sessions": "No session records found. Sessions are tracked from the next login.",
"profile_change_password_heading": "Change password",
"profile_update_password": "Update password",
"profile_updating": "Updating\u2026",
"profile_password_changed_ok": "Password changed successfully.",
"profile_playback_speed": "Playback speed \u2014 {speed}x",
"profile_subscription_heading": "Subscription",
"profile_plan_pro": "Pro",
"profile_plan_free": "Free",
"profile_pro_active": "Your Pro subscription is active.",
"profile_pro_perks": "Unlimited audio, all translation languages, and voice selection are enabled.",
"profile_manage_subscription": "Manage subscription",
"profile_upgrade_heading": "Upgrade to Pro",
"profile_upgrade_desc": "Unlock unlimited audio, translations in 4 languages, and voice selection.",
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",
"user_joined": "Joined {date}",
"user_followers_label": "followers",
"user_following_label": "following",
"user_no_books": "No books in library yet.",
"admin_pages_label": "Pages",
"admin_tools_label": "Tools",
"admin_scrape_status_idle": "Idle",
"admin_scrape_status_running": "Running",
"admin_scrape_full_catalogue": "Full catalogue",
"admin_scrape_single_book": "Single book",
"admin_scrape_quick_genres": "Quick genres",
"admin_scrape_task_history": "Task history",
"admin_scrape_filter_placeholder": "Filter by kind, status or URL\u2026",
"admin_scrape_no_matching": "No matching tasks.",
"admin_scrape_start": "Start scrape",
"admin_scrape_queuing": "Queuing\u2026",
"admin_scrape_running": "Running\u2026",
"admin_audio_filter_jobs": "Filter by slug, voice or status\u2026",
"admin_audio_filter_cache": "Filter by slug, chapter or voice\u2026",
"admin_audio_no_matching_jobs": "No matching jobs.",
"admin_audio_no_jobs": "No audio jobs yet.",
"admin_audio_cache_empty": "Audio cache is empty.",
"admin_audio_no_cache_results": "No results.",
"admin_changelog_gitea": "Gitea releases",
"admin_changelog_no_releases": "No releases found.",
"admin_changelog_load_error": "Could not load releases: {error}",
"comments_top": "Top",
"comments_new": "New",
"comments_posting": "Posting\u2026",
"comments_login_link": "Log in",
"comments_login_suffix": "to leave a comment.",
"comments_anonymous": "Anonymous",
"reader_audio_narration": "Audio Narration",
"reader_playing": "Playing \u2014 controls below",
"reader_paused": "Paused \u2014 controls below",
"reader_ch_ready": "Ch.{n} ready",
"reader_ch_preparing": "Preparing Ch.{n}\u2026 {percent}%",
"reader_ch_generate_on_nav": "Ch.{n} will generate on navigate",
"reader_now_playing": "Now playing: {title}",
"reader_load_this_chapter": "Load this chapter",
"reader_generate_samples": "Generate missing samples",
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
"reader_choose_voice": "Choose Voice",
"reader_generating_narration": "Generating narration\u2026",
"profile_font_family": "Font Family",
"profile_font_system": "System",
"profile_font_serif": "Serif",
"profile_font_mono": "Monospace",
"profile_text_size": "Text Size",
"profile_text_size_sm": "Small",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Large",
"profile_text_size_xl": "X-Large"
}

409
ui/messages/fr.json Normal file
View File

@@ -0,0 +1,409 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feedback": "Retour",
"nav_admin": "Admin",
"nav_profile": "Profil",
"nav_sign_in": "Connexion",
"nav_sign_out": "Déconnexion",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panneau admin",
"footer_library": "Bibliothèque",
"footer_catalogue": "Catalogue",
"footer_feedback": "Retour",
"footer_disclaimer": "Avertissement",
"footer_privacy": "Confidentialité",
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livres",
"home_stat_chapters": "Chapitres",
"home_stat_in_progress": "En cours",
"home_continue_reading": "Continuer la lecture",
"home_view_all": "Voir tout",
"home_recently_updated": "Récemment mis à jour",
"home_from_following": "Des personnes que vous suivez",
"home_empty_title": "Votre bibliothèque est vide",
"home_empty_body": "Découvrez des romans et ajoutez-les à votre bibliothèque.",
"home_discover_novels": "Découvrir des romans",
"home_via_reader": "via {username}",
"home_chapter_badge": "ch.{n}",
"player_generating": "Génération… {percent}%",
"player_loading": "Chargement…",
"player_chapters": "Chapitres",
"player_chapter_n": "Chapitre {n}",
"player_toggle_chapter_list": "Liste des chapitres",
"player_chapter_list_label": "Liste des chapitres",
"player_close_chapter_list": "Fermer la liste des chapitres",
"player_rewind_15": "Reculer de 15 secondes",
"player_skip_30": "Avancer de 30 secondes",
"player_back_15": "15 s",
"player_forward_30": "+30 s",
"player_play": "Lecture",
"player_pause": "Pause",
"player_speed_label": "Vitesse {speed}x",
"player_seek_label": "Progression du chapitre",
"player_change_speed": "Changer la vitesse",
"player_auto_next_on": "Suivant auto activé",
"player_auto_next_off": "Suivant auto désactivé",
"player_auto_next_ready": "Suivant auto — Ch.{n} prêt",
"player_auto_next_preparing": "Suivant auto — préparation Ch.{n}…",
"player_auto_next_aria": "Suivant auto {state}",
"player_go_to_chapter": "Aller au chapitre",
"player_close": "Fermer le lecteur",
"login_page_title": "Connexion — libnovel",
"login_heading": "Se connecter à libnovel",
"login_subheading": "Choisissez un fournisseur pour continuer",
"login_continue_google": "Continuer avec Google",
"login_continue_github": "Continuer avec GitHub",
"login_terms_notice": "En vous connectant, vous acceptez nos conditions d'utilisation.",
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
"books_page_title": "Bibliothèque — libnovel",
"books_heading": "Votre bibliothèque",
"books_empty_title": "Aucun livre pour l'instant",
"books_empty_body": "Ajoutez des livres à votre bibliothèque en visitant une page de livre.",
"books_browse_catalogue": "Parcourir le catalogue",
"books_chapter_count": "{n} chapitres",
"books_last_read": "Dernier lu : Ch.{n}",
"books_reading_progress": "Ch.{current} / {total}",
"books_remove": "Supprimer",
"catalogue_page_title": "Catalogue — libnovel",
"catalogue_heading": "Catalogue",
"catalogue_search_placeholder": "Rechercher des romans…",
"catalogue_filter_genre": "Genre",
"catalogue_filter_status": "Statut",
"catalogue_filter_sort": "Trier",
"catalogue_sort_popular": "Populaire",
"catalogue_sort_new": "Nouveau",
"catalogue_sort_top_rated": "Mieux notés",
"catalogue_sort_rank": "Rang",
"catalogue_status_all": "Tous",
"catalogue_status_ongoing": "En cours",
"catalogue_status_completed": "Terminé",
"catalogue_genre_all": "Tous les genres",
"catalogue_clear_filters": "Effacer",
"catalogue_reset": "Réinitialiser",
"catalogue_no_results": "Aucun roman trouvé.",
"catalogue_loading": "Chargement…",
"catalogue_load_more": "Charger plus",
"catalogue_results_count": "{n} résultats",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
"book_detail_add_to_library": "Ajouter à la bibliothèque",
"book_detail_remove_from_library": "Retirer de la bibliothèque",
"book_detail_read_now": "Lire maintenant",
"book_detail_continue_reading": "Continuer la lecture",
"book_detail_start_reading": "Commencer la lecture",
"book_detail_chapters": "{n} chapitres",
"book_detail_status": "Statut",
"book_detail_author": "Auteur",
"book_detail_genres": "Genres",
"book_detail_description": "Description",
"book_detail_source": "Source",
"book_detail_rescrape": "Réextraire",
"book_detail_scraping": "Extraction en cours…",
"book_detail_in_library": "Dans la bibliothèque",
"chapters_page_title": "Chapitres — {title}",
"chapters_heading": "Chapitres",
"chapters_back_to_book": "Retour au livre",
"chapters_reading_now": "En cours de lecture",
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Lire la narration",
"reader_generating_audio": "Génération audio…",
"reader_signin_for_audio": "Narration audio disponible",
"reader_signin_audio_desc": "Connectez-vous pour écouter ce chapitre narré par l'IA.",
"reader_audio_error": "Échec de la génération audio.",
"reader_prev_chapter": "Chapitre précédent",
"reader_next_chapter": "Chapitre suivant",
"reader_back_to_chapters": "Retour aux chapitres",
"reader_chapter_n": "Chapitre {n}",
"reader_change_voice": "Changer de voix",
"reader_voice_panel_title": "Sélectionner une voix",
"reader_voice_kokoro": "Voix Kokoro",
"reader_voice_pocket": "Voix Pocket-TTS",
"reader_voice_play_sample": "Écouter un extrait",
"reader_voice_stop_sample": "Arrêter l'extrait",
"reader_voice_selected": "Sélectionné",
"reader_close_voice_panel": "Fermer le panneau vocal",
"reader_auto_next": "Suivant auto",
"reader_speed": "Vitesse",
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
"profile_change_avatar": "Changer l'avatar",
"profile_username": "Nom d'utilisateur",
"profile_email": "E-mail",
"profile_change_password": "Changer le mot de passe",
"profile_current_password": "Mot de passe actuel",
"profile_new_password": "Nouveau mot de passe",
"profile_confirm_password": "Confirmer le mot de passe",
"profile_save_password": "Enregistrer le mot de passe",
"profile_appearance_heading": "Apparence",
"profile_theme_label": "Thème",
"profile_theme_amber": "Ambre",
"profile_theme_slate": "Ardoise",
"profile_theme_rose": "Rose",
"profile_reading_heading": "Paramètres de lecture",
"profile_voice_label": "Voix par défaut",
"profile_speed_label": "Vitesse de lecture",
"profile_auto_next_label": "Chapitre suivant automatique",
"profile_save_settings": "Enregistrer les paramètres",
"profile_settings_saved": "Paramètres enregistrés.",
"profile_settings_error": "Impossible d'enregistrer les paramètres.",
"profile_password_saved": "Mot de passe modifié.",
"profile_password_error": "Impossible de modifier le mot de passe.",
"profile_sessions_heading": "Sessions actives",
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
"profile_joined": "Inscrit le {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Bibliothèque de {username}",
"user_follow": "Suivre",
"user_unfollow": "Ne plus suivre",
"user_followers": "{n} abonnés",
"user_following": "{n} abonnements",
"user_library_empty": "Aucun livre dans la bibliothèque.",
"error_not_found_title": "Page introuvable",
"error_not_found_body": "La page que vous cherchez n'existe pas.",
"error_generic_title": "Une erreur s'est produite",
"error_go_home": "Accueil",
"error_status": "Erreur {status}",
"admin_scrape_page_title": "Extraction — Admin",
"admin_scrape_heading": "Extraction",
"admin_scrape_catalogue": "Extraire le catalogue",
"admin_scrape_book": "Extraire un livre",
"admin_scrape_url_placeholder": "URL du livre sur novelfire.net",
"admin_scrape_range": "Plage de chapitres",
"admin_scrape_from": "De",
"admin_scrape_to": "À",
"admin_scrape_submit": "Extraire",
"admin_scrape_cancel": "Annuler",
"admin_scrape_status_pending": "En attente",
"admin_scrape_status_running": "En cours",
"admin_scrape_status_done": "Terminé",
"admin_scrape_status_failed": "Échoué",
"admin_scrape_status_cancelled": "Annulé",
"admin_tasks_heading": "Tâches récentes",
"admin_tasks_empty": "Aucune tâche pour l'instant.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tâches audio",
"admin_audio_empty": "Aucune tâche audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Commentaires",
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
"comments_placeholder": "Écrire un commentaire…",
"comments_submit": "Publier",
"comments_login_prompt": "Connectez-vous pour commenter.",
"comments_vote_up": "Vote positif",
"comments_vote_down": "Vote négatif",
"comments_delete": "Supprimer",
"comments_reply": "Répondre",
"comments_show_replies": "Afficher {n} réponses",
"comments_hide_replies": "Masquer les réponses",
"comments_edited": "modifié",
"comments_deleted": "[supprimé]",
"disclaimer_page_title": "Avertissement — libnovel",
"privacy_page_title": "Politique de confidentialité — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Conditions d'utilisation — libnovel",
"common_loading": "Chargement…",
"common_error": "Erreur",
"common_save": "Enregistrer",
"common_cancel": "Annuler",
"common_close": "Fermer",
"common_search": "Rechercher",
"common_back": "Retour",
"common_next": "Suivant",
"common_previous": "Précédent",
"common_yes": "Oui",
"common_no": "Non",
"common_on": "activé",
"common_off": "désactivé",
"locale_switcher_label": "Langue",
"books_empty_library": "Votre bibliothèque est vide.",
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
"books_empty_discover_link": "Découvrir",
"books_empty_discover_suffix": "apparaîtront ici.",
"books_count": "{n} livre{s}",
"catalogue_sort_updated": "Mis à jour",
"catalogue_search_button": "Rechercher",
"catalogue_refresh": "Actualiser",
"catalogue_refreshing": "En file d'attente…",
"catalogue_refresh_mobile": "Actualiser le catalogue",
"catalogue_all_loaded": "Tous les romans chargés",
"catalogue_scroll_top": "Retour en haut",
"catalogue_view_grid": "Vue grille",
"catalogue_view_list": "Vue liste",
"catalogue_browse_source": "Parcourir les romans de novelfire.net",
"catalogue_search_results": "{n} résultat{s} pour « {q} »",
"catalogue_search_local_count": "({local} local, {remote} depuis novelfire)",
"catalogue_rank_ranked": "{n} romans classés depuis le dernier scrape du catalogue",
"catalogue_rank_no_data": "Aucune donnée de classement.",
"catalogue_rank_no_data_body": "Aucune donnée de classement — lancez un scrape complet du catalogue pour remplir",
"catalogue_rank_run_scrape_admin": "Cliquez sur Actualiser le catalogue ci-dessus pour déclencher un scrape complet.",
"catalogue_rank_run_scrape_user": "Demandez à un administrateur d'effectuer un scrape du catalogue.",
"catalogue_scrape_queued_flash": "Scrape complet du catalogue en file d'attente. La bibliothèque et le classement seront mis à jour au fur et à mesure du traitement des livres.",
"catalogue_scrape_busy_flash": "Un job de scrape est déjà en cours. Revenez une fois terminé.",
"catalogue_scrape_error_flash": "Échec de la mise en file d'attente du scrape. Vérifiez que le service de scraper est accessible.",
"catalogue_filters_label": "Filtres",
"catalogue_apply": "Appliquer",
"catalogue_filter_rank_note": "Les filtres genre et statut s'appliquent uniquement à Parcourir",
"catalogue_no_results_search": "Aucun résultat trouvé.",
"catalogue_no_results_try": "Essayez un autre terme de recherche.",
"catalogue_no_results_filters": "Essayez d'autres filtres ou revenez plus tard.",
"catalogue_scrape_queued_badge": "En file",
"catalogue_scrape_busy_badge": "Scraper occupé",
"catalogue_scrape_busy_list": "Occupé",
"catalogue_scrape_forbidden_badge": "Interdit",
"catalogue_scrape_novel_button": "Extraire",
"catalogue_scraping_novel": "Extraction…",
"book_detail_not_in_library": "pas dans la bibliothèque",
"book_detail_continue_ch": "Continuer ch.{n}",
"book_detail_start_ch1": "Commencer au ch.1",
"book_detail_preview_ch1": "Aperçu ch.1",
"book_detail_reading_ch": "Lecture ch.{n} sur {total}",
"book_detail_n_chapters": "{n} chapitres",
"book_detail_rescraping": "En file d'attente…",
"book_detail_from_chapter": "À partir du chapitre",
"book_detail_to_chapter": "Jusqu'au chapitre (optionnel)",
"book_detail_range_queuing": "En file d'attente…",
"book_detail_scrape_range": "Plage d'extraction",
"book_detail_admin": "Admin",
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
"book_detail_scraping_home": "← Accueil",
"book_detail_rescrape_book": "Réextraire le livre",
"book_detail_less": "Moins",
"book_detail_more": "Plus",
"chapters_search_placeholder": "Rechercher des chapitres…",
"chapters_jump_to": "Aller au Ch.{n}",
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
"chapters_reading_indicator": "en cours",
"chapters_result_count": "{n} résultats",
"reader_fetching_chapter": "Récupération du chapitre…",
"reader_words": "{n} mots",
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
"profile_tts_voice": "Voix TTS",
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
"profile_saving": "Enregistrement…",
"profile_saved": "Enregistré !",
"profile_session_this": "Cette session",
"profile_session_signed_in": "Connecté le {date}",
"profile_session_last_seen": "· Dernière activité {date}",
"profile_session_sign_out": "Se déconnecter",
"profile_session_end": "Terminer",
"profile_session_unrecognised": "Ce sont tous les appareils connectés à votre compte. Terminez toute session que vous ne reconnaissez pas.",
"profile_no_sessions": "Aucun enregistrement de session trouvé. Les sessions sont suivies dès la prochaine connexion.",
"profile_change_password_heading": "Changer le mot de passe",
"profile_update_password": "Mettre à jour le mot de passe",
"profile_updating": "Mise à jour…",
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
"profile_playback_speed": "Vitesse de lecture — {speed}x",
"profile_subscription_heading": "Abonnement",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuit",
"profile_pro_active": "Votre abonnement Pro est actif.",
"profile_pro_perks": "Audio illimité, toutes les langues de traduction et la sélection de voix sont activées.",
"profile_manage_subscription": "Gérer l'abonnement",
"profile_upgrade_heading": "Passer au Pro",
"profile_upgrade_desc": "Débloquez l'audio illimité, les traductions en 4 langues et la sélection de voix.",
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
"user_joined": "Inscrit le {date}",
"user_followers_label": "abonnés",
"user_following_label": "abonnements",
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
"admin_pages_label": "Pages",
"admin_tools_label": "Outils",
"admin_scrape_status_idle": "Inactif",
"admin_scrape_full_catalogue": "Catalogue complet",
"admin_scrape_single_book": "Livre unique",
"admin_scrape_quick_genres": "Genres rapides",
"admin_scrape_task_history": "Historique des tâches",
"admin_scrape_filter_placeholder": "Filtrer par type, statut ou URL…",
"admin_scrape_no_matching": "Aucune tâche correspondante.",
"admin_scrape_start": "Démarrer l'extraction",
"admin_scrape_queuing": "En file d'attente…",
"admin_scrape_running": "En cours…",
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
"admin_audio_cache_empty": "Cache audio vide.",
"admin_audio_no_cache_results": "Aucun résultat.",
"admin_changelog_gitea": "Releases Gitea",
"admin_changelog_no_releases": "Aucune release trouvée.",
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
"comments_top": "Les meilleures",
"comments_new": "Nouvelles",
"comments_posting": "Publication…",
"comments_login_link": "Connectez-vous",
"comments_login_suffix": "pour laisser un commentaire.",
"comments_anonymous": "Anonyme",
"reader_audio_narration": "Narration Audio",
"reader_playing": "Lecture en cours — contrôles ci-dessous",
"reader_paused": "En pause — contrôles ci-dessous",
"reader_ch_ready": "Ch.{n} prêt",
"reader_ch_preparing": "Préparation Ch.{n}… {percent}%",
"reader_ch_generate_on_nav": "Ch.{n} sera généré lors de la navigation",
"reader_now_playing": "En cours : {title}",
"reader_load_this_chapter": "Charger ce chapitre",
"reader_generate_samples": "Générer les échantillons manquants",
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
"reader_choose_voice": "Choisir une voix",
"reader_generating_narration": "Génération de la narration…",
"profile_font_family": "Police",
"profile_font_system": "Système",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Taille du texte",
"profile_text_size_sm": "Petit",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand"
}

409
ui/messages/id.json Normal file
View File

@@ -0,0 +1,409 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feedback": "Masukan",
"nav_admin": "Admin",
"nav_profile": "Profil",
"nav_sign_in": "Masuk",
"nav_sign_out": "Keluar",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panel admin",
"footer_library": "Perpustakaan",
"footer_catalogue": "Katalog",
"footer_feedback": "Masukan",
"footer_disclaimer": "Penyangkalan",
"footer_privacy": "Privasi",
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Buku",
"home_stat_chapters": "Bab",
"home_stat_in_progress": "Sedang dibaca",
"home_continue_reading": "Lanjutkan Membaca",
"home_view_all": "Lihat semua",
"home_recently_updated": "Baru Diperbarui",
"home_from_following": "Dari Orang yang Kamu Ikuti",
"home_empty_title": "Perpustakaanmu kosong",
"home_empty_body": "Temukan novel dan tambahkan ke perpustakaanmu.",
"home_discover_novels": "Temukan Novel",
"home_via_reader": "via {username}",
"home_chapter_badge": "bab.{n}",
"player_generating": "Membuat… {percent}%",
"player_loading": "Memuat…",
"player_chapters": "Bab",
"player_chapter_n": "Bab {n}",
"player_toggle_chapter_list": "Daftar bab",
"player_chapter_list_label": "Daftar bab",
"player_close_chapter_list": "Tutup daftar bab",
"player_rewind_15": "Mundur 15 detik",
"player_skip_30": "Maju 30 detik",
"player_back_15": "15 dtk",
"player_forward_30": "+30 dtk",
"player_play": "Putar",
"player_pause": "Jeda",
"player_speed_label": "Kecepatan {speed}x",
"player_seek_label": "Kemajuan bab",
"player_change_speed": "Ubah kecepatan",
"player_auto_next_on": "Auto-lanjut aktif",
"player_auto_next_off": "Auto-lanjut nonaktif",
"player_auto_next_ready": "Auto-lanjut — Bab.{n} siap",
"player_auto_next_preparing": "Auto-lanjut — menyiapkan Bab.{n}…",
"player_auto_next_aria": "Auto-lanjut {state}",
"player_go_to_chapter": "Pergi ke bab",
"player_close": "Tutup pemutar",
"login_page_title": "Masuk — libnovel",
"login_heading": "Masuk ke libnovel",
"login_subheading": "Pilih penyedia untuk melanjutkan",
"login_continue_google": "Lanjutkan dengan Google",
"login_continue_github": "Lanjutkan dengan GitHub",
"login_terms_notice": "Dengan masuk, kamu menyetujui syarat layanan kami.",
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
"books_page_title": "Perpustakaan — libnovel",
"books_heading": "Perpustakaanmu",
"books_empty_title": "Belum ada buku",
"books_empty_body": "Tambahkan buku ke perpustakaanmu dengan mengunjungi halaman buku.",
"books_browse_catalogue": "Jelajahi Katalog",
"books_chapter_count": "{n} bab",
"books_last_read": "Terakhir: Bab.{n}",
"books_reading_progress": "Bab.{current} / {total}",
"books_remove": "Hapus",
"catalogue_page_title": "Katalog — libnovel",
"catalogue_heading": "Katalog",
"catalogue_search_placeholder": "Cari novel…",
"catalogue_filter_genre": "Genre",
"catalogue_filter_status": "Status",
"catalogue_filter_sort": "Urutkan",
"catalogue_sort_popular": "Populer",
"catalogue_sort_new": "Terbaru",
"catalogue_sort_top_rated": "Nilai Tertinggi",
"catalogue_sort_rank": "Peringkat",
"catalogue_status_all": "Semua",
"catalogue_status_ongoing": "Berlangsung",
"catalogue_status_completed": "Selesai",
"catalogue_genre_all": "Semua genre",
"catalogue_clear_filters": "Hapus",
"catalogue_reset": "Atur ulang",
"catalogue_no_results": "Novel tidak ditemukan.",
"catalogue_loading": "Memuat…",
"catalogue_load_more": "Muat lebih banyak",
"catalogue_results_count": "{n} hasil",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Masuk untuk menyimpan",
"book_detail_add_to_library": "Tambah ke Perpustakaan",
"book_detail_remove_from_library": "Hapus dari Perpustakaan",
"book_detail_read_now": "Baca Sekarang",
"book_detail_continue_reading": "Lanjutkan Membaca",
"book_detail_start_reading": "Mulai Membaca",
"book_detail_chapters": "{n} Bab",
"book_detail_status": "Status",
"book_detail_author": "Penulis",
"book_detail_genres": "Genre",
"book_detail_description": "Deskripsi",
"book_detail_source": "Sumber",
"book_detail_rescrape": "Perbarui",
"book_detail_scraping": "Memperbarui…",
"book_detail_in_library": "Ada di Perpustakaan",
"chapters_page_title": "Bab — {title}",
"chapters_heading": "Bab",
"chapters_back_to_book": "Kembali ke buku",
"chapters_reading_now": "Sedang dibaca",
"chapters_empty": "Belum ada bab yang diambil.",
"reader_page_title": "{title} — Bab.{n} — libnovel",
"reader_play_narration": "Putar narasi",
"reader_generating_audio": "Membuat audio…",
"reader_signin_for_audio": "Narasi audio tersedia",
"reader_signin_audio_desc": "Masuk untuk mendengarkan bab ini yang dinarasikan oleh AI.",
"reader_audio_error": "Pembuatan audio gagal.",
"reader_prev_chapter": "Bab sebelumnya",
"reader_next_chapter": "Bab berikutnya",
"reader_back_to_chapters": "Kembali ke daftar bab",
"reader_chapter_n": "Bab {n}",
"reader_change_voice": "Ganti suara",
"reader_voice_panel_title": "Pilih suara",
"reader_voice_kokoro": "Suara Kokoro",
"reader_voice_pocket": "Suara Pocket-TTS",
"reader_voice_play_sample": "Putar sampel",
"reader_voice_stop_sample": "Hentikan sampel",
"reader_voice_selected": "Dipilih",
"reader_close_voice_panel": "Tutup panel suara",
"reader_auto_next": "Auto-lanjut",
"reader_speed": "Kecepatan",
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
"profile_change_avatar": "Ubah avatar",
"profile_username": "Nama pengguna",
"profile_email": "Email",
"profile_change_password": "Ubah kata sandi",
"profile_current_password": "Kata sandi saat ini",
"profile_new_password": "Kata sandi baru",
"profile_confirm_password": "Konfirmasi kata sandi",
"profile_save_password": "Simpan kata sandi",
"profile_appearance_heading": "Tampilan",
"profile_theme_label": "Tema",
"profile_theme_amber": "Amber",
"profile_theme_slate": "Abu-abu",
"profile_theme_rose": "Mawar",
"profile_reading_heading": "Pengaturan membaca",
"profile_voice_label": "Suara default",
"profile_speed_label": "Kecepatan pemutaran",
"profile_auto_next_label": "Auto-lanjut bab",
"profile_save_settings": "Simpan pengaturan",
"profile_settings_saved": "Pengaturan disimpan.",
"profile_settings_error": "Gagal menyimpan pengaturan.",
"profile_password_saved": "Kata sandi diubah.",
"profile_password_error": "Gagal mengubah kata sandi.",
"profile_sessions_heading": "Sesi aktif",
"profile_sign_out_all": "Keluar dari semua perangkat lain",
"profile_joined": "Bergabung {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Perpustakaan {username}",
"user_follow": "Ikuti",
"user_unfollow": "Berhenti mengikuti",
"user_followers": "{n} pengikut",
"user_following": "{n} mengikuti",
"user_library_empty": "Tidak ada buku di perpustakaan.",
"error_not_found_title": "Halaman tidak ditemukan",
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
"error_generic_title": "Terjadi kesalahan",
"error_go_home": "Ke beranda",
"error_status": "Error {status}",
"admin_scrape_page_title": "Scrape — Admin",
"admin_scrape_heading": "Scrape",
"admin_scrape_catalogue": "Scrape Katalog",
"admin_scrape_book": "Scrape Buku",
"admin_scrape_url_placeholder": "URL buku di novelfire.net",
"admin_scrape_range": "Rentang bab",
"admin_scrape_from": "Dari",
"admin_scrape_to": "Sampai",
"admin_scrape_submit": "Scrape",
"admin_scrape_cancel": "Batal",
"admin_scrape_status_pending": "Menunggu",
"admin_scrape_status_running": "Berjalan",
"admin_scrape_status_done": "Selesai",
"admin_scrape_status_failed": "Gagal",
"admin_scrape_status_cancelled": "Dibatalkan",
"admin_tasks_heading": "Tugas terbaru",
"admin_tasks_empty": "Belum ada tugas.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tugas Audio",
"admin_audio_empty": "Tidak ada tugas audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Komentar",
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
"comments_placeholder": "Tulis komentar…",
"comments_submit": "Kirim",
"comments_login_prompt": "Masuk untuk berkomentar.",
"comments_vote_up": "Suka",
"comments_vote_down": "Tidak suka",
"comments_delete": "Hapus",
"comments_reply": "Balas",
"comments_show_replies": "Tampilkan {n} balasan",
"comments_hide_replies": "Sembunyikan balasan",
"comments_edited": "diedit",
"comments_deleted": "[dihapus]",
"disclaimer_page_title": "Penyangkalan — libnovel",
"privacy_page_title": "Kebijakan Privasi — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Syarat Layanan — libnovel",
"common_loading": "Memuat…",
"common_error": "Error",
"common_save": "Simpan",
"common_cancel": "Batal",
"common_close": "Tutup",
"common_search": "Cari",
"common_back": "Kembali",
"common_next": "Berikutnya",
"common_previous": "Sebelumnya",
"common_yes": "Ya",
"common_no": "Tidak",
"common_on": "aktif",
"common_off": "nonaktif",
"locale_switcher_label": "Bahasa",
"books_empty_library": "Perpustakaanmu kosong.",
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
"books_empty_discover_link": "Temukan",
"books_empty_discover_suffix": "akan muncul di sini.",
"books_count": "{n} buku",
"catalogue_sort_updated": "Diperbarui",
"catalogue_search_button": "Cari",
"catalogue_refresh": "Segarkan",
"catalogue_refreshing": "Mengantri…",
"catalogue_refresh_mobile": "Segarkan katalog",
"catalogue_all_loaded": "Semua novel telah dimuat",
"catalogue_scroll_top": "Kembali ke atas",
"catalogue_view_grid": "Tampilan kisi",
"catalogue_view_list": "Tampilan daftar",
"catalogue_browse_source": "Jelajahi novel dari novelfire.net",
"catalogue_search_results": "{n} hasil untuk \"{q}\"",
"catalogue_search_local_count": "({local} lokal, {remote} dari novelfire)",
"catalogue_rank_ranked": "{n} novel diurutkan dari scrape katalog terakhir",
"catalogue_rank_no_data": "Tidak ada data peringkat.",
"catalogue_rank_no_data_body": "Tidak ada data peringkat — jalankan scrape katalog penuh untuk mengisi",
"catalogue_rank_run_scrape_admin": "Klik Segarkan katalog di atas untuk memicu scrape katalog penuh.",
"catalogue_rank_run_scrape_user": "Minta admin untuk menjalankan scrape katalog.",
"catalogue_scrape_queued_flash": "Scrape katalog penuh diantrekan. Perpustakaan dan peringkat akan diperbarui saat buku diproses.",
"catalogue_scrape_busy_flash": "Pekerjaan scrape sedang berjalan. Periksa kembali setelah selesai.",
"catalogue_scrape_error_flash": "Gagal mengantrekan scrape. Pastikan layanan scraper dapat dijangkau.",
"catalogue_filters_label": "Filter",
"catalogue_apply": "Terapkan",
"catalogue_filter_rank_note": "Filter genre & status hanya berlaku untuk Jelajahi",
"catalogue_no_results_search": "Tidak ada hasil.",
"catalogue_no_results_try": "Coba kata kunci lain.",
"catalogue_no_results_filters": "Coba filter lain atau periksa kembali nanti.",
"catalogue_scrape_queued_badge": "Diantrekan",
"catalogue_scrape_busy_badge": "Scraper sibuk",
"catalogue_scrape_busy_list": "Sibuk",
"catalogue_scrape_forbidden_badge": "Terlarang",
"catalogue_scrape_novel_button": "Scrape",
"catalogue_scraping_novel": "Scraping…",
"book_detail_not_in_library": "tidak di perpustakaan",
"book_detail_continue_ch": "Lanjutkan bab.{n}",
"book_detail_start_ch1": "Mulai dari bab.1",
"book_detail_preview_ch1": "Pratinjau bab.1",
"book_detail_reading_ch": "Membaca bab.{n} dari {total}",
"book_detail_n_chapters": "{n} bab",
"book_detail_rescraping": "Mengantri…",
"book_detail_from_chapter": "Dari bab",
"book_detail_to_chapter": "Sampai bab (opsional)",
"book_detail_range_queuing": "Mengantri…",
"book_detail_scrape_range": "Rentang scrape",
"book_detail_admin": "Admin",
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
"book_detail_scraping_home": "← Beranda",
"book_detail_rescrape_book": "Scrape ulang buku",
"book_detail_less": "Lebih sedikit",
"book_detail_more": "Selengkapnya",
"chapters_search_placeholder": "Cari bab…",
"chapters_jump_to": "Loncat ke Bab.{n}",
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
"chapters_none_available": "Belum ada bab tersedia.",
"chapters_reading_indicator": "sedang dibaca",
"chapters_result_count": "{n} hasil",
"reader_fetching_chapter": "Mengambil bab…",
"reader_words": "{n} kata",
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
"profile_click_to_change": "Klik avatar untuk mengganti foto",
"profile_tts_voice": "Suara TTS",
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
"profile_saving": "Menyimpan…",
"profile_saved": "Tersimpan!",
"profile_session_this": "Sesi ini",
"profile_session_signed_in": "Masuk {date}",
"profile_session_last_seen": "· Terakhir dilihat {date}",
"profile_session_sign_out": "Keluar",
"profile_session_end": "Akhiri",
"profile_session_unrecognised": "Ini semua perangkat yang masuk ke akunmu. Akhiri sesi yang tidak kamu kenali.",
"profile_no_sessions": "Tidak ada catatan sesi. Sesi dilacak mulai login berikutnya.",
"profile_change_password_heading": "Ubah kata sandi",
"profile_update_password": "Perbarui kata sandi",
"profile_updating": "Memperbarui…",
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
"profile_subscription_heading": "Langganan",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratis",
"profile_pro_active": "Langganan Pro kamu aktif.",
"profile_pro_perks": "Audio tanpa batas, semua bahasa terjemahan, dan pilihan suara tersedia.",
"profile_manage_subscription": "Kelola langganan",
"profile_upgrade_heading": "Tingkatkan ke Pro",
"profile_upgrade_desc": "Buka audio tanpa batas, terjemahan dalam 4 bahasa, dan pilihan suara.",
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
"user_joined": "Bergabung {date}",
"user_followers_label": "pengikut",
"user_following_label": "mengikuti",
"user_no_books": "Belum ada buku di perpustakaan.",
"admin_pages_label": "Halaman",
"admin_tools_label": "Alat",
"admin_scrape_status_idle": "Menunggu",
"admin_scrape_full_catalogue": "Katalog penuh",
"admin_scrape_single_book": "Satu buku",
"admin_scrape_quick_genres": "Genre cepat",
"admin_scrape_task_history": "Riwayat tugas",
"admin_scrape_filter_placeholder": "Filter berdasarkan jenis, status, atau URL…",
"admin_scrape_no_matching": "Tidak ada tugas yang cocok.",
"admin_scrape_start": "Mulai scrape",
"admin_scrape_queuing": "Mengantri…",
"admin_scrape_running": "Berjalan…",
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
"admin_audio_cache_empty": "Cache audio kosong.",
"admin_audio_no_cache_results": "Tidak ada hasil.",
"admin_changelog_gitea": "Rilis Gitea",
"admin_changelog_no_releases": "Tidak ada rilis.",
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
"comments_top": "Teratas",
"comments_new": "Terbaru",
"comments_posting": "Mengirim…",
"comments_login_link": "Masuk",
"comments_login_suffix": "untuk meninggalkan komentar.",
"comments_anonymous": "Anonim",
"reader_audio_narration": "Narasi Audio",
"reader_playing": "Memutar — kontrol di bawah",
"reader_paused": "Dijeda — kontrol di bawah",
"reader_ch_ready": "Bab.{n} siap",
"reader_ch_preparing": "Menyiapkan Bab.{n}… {percent}%",
"reader_ch_generate_on_nav": "Bab.{n} akan dihasilkan saat navigasi",
"reader_now_playing": "Sedang diputar: {title}",
"reader_load_this_chapter": "Muat bab ini",
"reader_generate_samples": "Hasilkan sampel yang hilang",
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
"reader_choose_voice": "Pilih Suara",
"reader_generating_narration": "Membuat narasi…",
"profile_font_family": "Jenis Font",
"profile_font_system": "Sistem",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Ukuran Teks",
"profile_text_size_sm": "Kecil",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar"
}

409
ui/messages/pt-BR.json Normal file
View File

@@ -0,0 +1,409 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Perfil",
"nav_sign_in": "Entrar",
"nav_sign_out": "Sair",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Painel admin",
"footer_library": "Biblioteca",
"footer_catalogue": "Catálogo",
"footer_feedback": "Feedback",
"footer_disclaimer": "Aviso legal",
"footer_privacy": "Privacidade",
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livros",
"home_stat_chapters": "Capítulos",
"home_stat_in_progress": "Em andamento",
"home_continue_reading": "Continuar Lendo",
"home_view_all": "Ver tudo",
"home_recently_updated": "Atualizados Recentemente",
"home_from_following": "De Quem Você Segue",
"home_empty_title": "Sua biblioteca está vazia",
"home_empty_body": "Descubra romances e adicione à sua biblioteca.",
"home_discover_novels": "Descobrir Romances",
"home_via_reader": "via {username}",
"home_chapter_badge": "cap.{n}",
"player_generating": "Gerando… {percent}%",
"player_loading": "Carregando…",
"player_chapters": "Capítulos",
"player_chapter_n": "Capítulo {n}",
"player_toggle_chapter_list": "Lista de capítulos",
"player_chapter_list_label": "Lista de capítulos",
"player_close_chapter_list": "Fechar lista de capítulos",
"player_rewind_15": "Voltar 15 segundos",
"player_skip_30": "Avançar 30 segundos",
"player_back_15": "15 s",
"player_forward_30": "+30 s",
"player_play": "Reproduzir",
"player_pause": "Pausar",
"player_speed_label": "Velocidade {speed}x",
"player_seek_label": "Progresso do capítulo",
"player_change_speed": "Mudar velocidade",
"player_auto_next_on": "Próximo automático ativado",
"player_auto_next_off": "Próximo automático desativado",
"player_auto_next_ready": "Próximo automático — Cap.{n} pronto",
"player_auto_next_preparing": "Próximo automático — preparando Cap.{n}…",
"player_auto_next_aria": "Próximo automático {state}",
"player_go_to_chapter": "Ir para capítulo",
"player_close": "Fechar player",
"login_page_title": "Entrar — libnovel",
"login_heading": "Entrar no libnovel",
"login_subheading": "Escolha um provedor para continuar",
"login_continue_google": "Continuar com Google",
"login_continue_github": "Continuar com GitHub",
"login_terms_notice": "Ao entrar, você concorda com nossos termos de serviço.",
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
"books_page_title": "Biblioteca — libnovel",
"books_heading": "Sua Biblioteca",
"books_empty_title": "Nenhum livro ainda",
"books_empty_body": "Adicione livros à sua biblioteca visitando a página de um livro.",
"books_browse_catalogue": "Explorar Catálogo",
"books_chapter_count": "{n} capítulos",
"books_last_read": "Último: Cap.{n}",
"books_reading_progress": "Cap.{current} / {total}",
"books_remove": "Remover",
"catalogue_page_title": "Catálogo — libnovel",
"catalogue_heading": "Catálogo",
"catalogue_search_placeholder": "Pesquisar romances…",
"catalogue_filter_genre": "Gênero",
"catalogue_filter_status": "Status",
"catalogue_filter_sort": "Ordenar",
"catalogue_sort_popular": "Popular",
"catalogue_sort_new": "Novo",
"catalogue_sort_top_rated": "Mais Bem Avaliados",
"catalogue_sort_rank": "Ranking",
"catalogue_status_all": "Todos",
"catalogue_status_ongoing": "Em andamento",
"catalogue_status_completed": "Concluído",
"catalogue_genre_all": "Todos os gêneros",
"catalogue_clear_filters": "Limpar",
"catalogue_reset": "Redefinir",
"catalogue_no_results": "Nenhum romance encontrado.",
"catalogue_loading": "Carregando…",
"catalogue_load_more": "Carregar mais",
"catalogue_results_count": "{n} resultados",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Entre para salvar",
"book_detail_add_to_library": "Adicionar à Biblioteca",
"book_detail_remove_from_library": "Remover da Biblioteca",
"book_detail_read_now": "Ler Agora",
"book_detail_continue_reading": "Continuar Lendo",
"book_detail_start_reading": "Começar a Ler",
"book_detail_chapters": "{n} Capítulos",
"book_detail_status": "Status",
"book_detail_author": "Autor",
"book_detail_genres": "Gêneros",
"book_detail_description": "Descrição",
"book_detail_source": "Fonte",
"book_detail_rescrape": "Atualizar",
"book_detail_scraping": "Atualizando…",
"book_detail_in_library": "Na Biblioteca",
"chapters_page_title": "Capítulos — {title}",
"chapters_heading": "Capítulos",
"chapters_back_to_book": "Voltar ao livro",
"chapters_reading_now": "Lendo",
"chapters_empty": "Nenhum capítulo extraído ainda.",
"reader_page_title": "{title} — Cap.{n} — libnovel",
"reader_play_narration": "Reproduzir narração",
"reader_generating_audio": "Gerando áudio…",
"reader_signin_for_audio": "Narração de áudio disponível",
"reader_signin_audio_desc": "Entre para ouvir este capítulo narrado por IA.",
"reader_audio_error": "Falha na geração de áudio.",
"reader_prev_chapter": "Capítulo anterior",
"reader_next_chapter": "Próximo capítulo",
"reader_back_to_chapters": "Voltar aos capítulos",
"reader_chapter_n": "Capítulo {n}",
"reader_change_voice": "Mudar voz",
"reader_voice_panel_title": "Selecionar voz",
"reader_voice_kokoro": "Vozes Kokoro",
"reader_voice_pocket": "Vozes Pocket-TTS",
"reader_voice_play_sample": "Reproduzir amostra",
"reader_voice_stop_sample": "Parar amostra",
"reader_voice_selected": "Selecionado",
"reader_close_voice_panel": "Fechar painel de voz",
"reader_auto_next": "Próximo automático",
"reader_speed": "Velocidade",
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
"profile_page_title": "Perfil — libnovel",
"profile_heading": "Perfil",
"profile_avatar_label": "Avatar",
"profile_change_avatar": "Mudar avatar",
"profile_username": "Nome de usuário",
"profile_email": "Email",
"profile_change_password": "Mudar senha",
"profile_current_password": "Senha atual",
"profile_new_password": "Nova senha",
"profile_confirm_password": "Confirmar senha",
"profile_save_password": "Salvar senha",
"profile_appearance_heading": "Aparência",
"profile_theme_label": "Tema",
"profile_theme_amber": "Âmbar",
"profile_theme_slate": "Ardósia",
"profile_theme_rose": "Rosa",
"profile_reading_heading": "Configurações de leitura",
"profile_voice_label": "Voz padrão",
"profile_speed_label": "Velocidade de reprodução",
"profile_auto_next_label": "Próximo capítulo automático",
"profile_save_settings": "Salvar configurações",
"profile_settings_saved": "Configurações salvas.",
"profile_settings_error": "Falha ao salvar configurações.",
"profile_password_saved": "Senha alterada.",
"profile_password_error": "Falha ao alterar a senha.",
"profile_sessions_heading": "Sessões ativas",
"profile_sign_out_all": "Sair de todos os outros dispositivos",
"profile_joined": "Entrou em {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Biblioteca de {username}",
"user_follow": "Seguir",
"user_unfollow": "Deixar de seguir",
"user_followers": "{n} seguidores",
"user_following": "{n} seguindo",
"user_library_empty": "Nenhum livro na biblioteca.",
"error_not_found_title": "Página não encontrada",
"error_not_found_body": "A página que você procura não existe.",
"error_generic_title": "Algo deu errado",
"error_go_home": "Ir para início",
"error_status": "Erro {status}",
"admin_scrape_page_title": "Extração — Admin",
"admin_scrape_heading": "Extração",
"admin_scrape_catalogue": "Extrair Catálogo",
"admin_scrape_book": "Extrair Livro",
"admin_scrape_url_placeholder": "URL do livro em novelfire.net",
"admin_scrape_range": "Intervalo de capítulos",
"admin_scrape_from": "De",
"admin_scrape_to": "Até",
"admin_scrape_submit": "Extrair",
"admin_scrape_cancel": "Cancelar",
"admin_scrape_status_pending": "Pendente",
"admin_scrape_status_running": "Em execução",
"admin_scrape_status_done": "Concluído",
"admin_scrape_status_failed": "Falhou",
"admin_scrape_status_cancelled": "Cancelado",
"admin_tasks_heading": "Tarefas recentes",
"admin_tasks_empty": "Nenhuma tarefa ainda.",
"admin_audio_page_title": "Áudio — Admin",
"admin_audio_heading": "Tarefas de Áudio",
"admin_audio_empty": "Nenhuma tarefa de áudio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Comentários",
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
"comments_placeholder": "Escreva um comentário…",
"comments_submit": "Publicar",
"comments_login_prompt": "Entre para comentar.",
"comments_vote_up": "Votar positivo",
"comments_vote_down": "Votar negativo",
"comments_delete": "Excluir",
"comments_reply": "Responder",
"comments_show_replies": "Mostrar {n} respostas",
"comments_hide_replies": "Ocultar respostas",
"comments_edited": "editado",
"comments_deleted": "[excluído]",
"disclaimer_page_title": "Aviso Legal — libnovel",
"privacy_page_title": "Política de Privacidade — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Termos de Serviço — libnovel",
"common_loading": "Carregando…",
"common_error": "Erro",
"common_save": "Salvar",
"common_cancel": "Cancelar",
"common_close": "Fechar",
"common_search": "Pesquisar",
"common_back": "Voltar",
"common_next": "Próximo",
"common_previous": "Anterior",
"common_yes": "Sim",
"common_no": "Não",
"common_on": "ativado",
"common_off": "desativado",
"locale_switcher_label": "Idioma",
"books_empty_library": "Sua biblioteca está vazia.",
"books_empty_discover": "Livros que você começar a ler ou salvar de",
"books_empty_discover_link": "Descobrir",
"books_empty_discover_suffix": "aparecerão aqui.",
"books_count": "{n} livro{s}",
"catalogue_sort_updated": "Atualizado",
"catalogue_search_button": "Pesquisar",
"catalogue_refresh": "Atualizar",
"catalogue_refreshing": "Na fila…",
"catalogue_refresh_mobile": "Atualizar catálogo",
"catalogue_all_loaded": "Todos os romances carregados",
"catalogue_scroll_top": "Voltar ao topo",
"catalogue_view_grid": "Visualização em grade",
"catalogue_view_list": "Visualização em lista",
"catalogue_browse_source": "Explorar romances do novelfire.net",
"catalogue_search_results": "{n} resultado{s} para \"{q}\"",
"catalogue_search_local_count": "({local} local, {remote} do novelfire)",
"catalogue_rank_ranked": "{n} romances classificados do último scrape do catálogo",
"catalogue_rank_no_data": "Sem dados de classificação.",
"catalogue_rank_no_data_body": "Sem dados de classificação — execute um scrape completo do catálogo para preencher",
"catalogue_rank_run_scrape_admin": "Clique em Atualizar catálogo acima para acionar um scrape completo.",
"catalogue_rank_run_scrape_user": "Peça a um administrador para executar um scrape do catálogo.",
"catalogue_scrape_queued_flash": "Scrape completo do catálogo na fila. A biblioteca e a classificação serão atualizadas conforme os livros forem processados.",
"catalogue_scrape_busy_flash": "Um job de scrape já está em execução. Volte quando terminar.",
"catalogue_scrape_error_flash": "Falha ao enfileirar o scrape. Verifique se o serviço de scraper está acessível.",
"catalogue_filters_label": "Filtros",
"catalogue_apply": "Aplicar",
"catalogue_filter_rank_note": "Filtros de gênero e status se aplicam apenas a Explorar",
"catalogue_no_results_search": "Nenhum resultado encontrado.",
"catalogue_no_results_try": "Tente um termo de pesquisa diferente.",
"catalogue_no_results_filters": "Tente filtros diferentes ou volte mais tarde.",
"catalogue_scrape_queued_badge": "Na fila",
"catalogue_scrape_busy_badge": "Scraper ocupado",
"catalogue_scrape_busy_list": "Ocupado",
"catalogue_scrape_forbidden_badge": "Proibido",
"catalogue_scrape_novel_button": "Extrair",
"catalogue_scraping_novel": "Extraindo…",
"book_detail_not_in_library": "não está na biblioteca",
"book_detail_continue_ch": "Continuar cap.{n}",
"book_detail_start_ch1": "Começar pelo cap.1",
"book_detail_preview_ch1": "Prévia do cap.1",
"book_detail_reading_ch": "Lendo cap.{n} de {total}",
"book_detail_n_chapters": "{n} capítulos",
"book_detail_rescraping": "Na fila…",
"book_detail_from_chapter": "A partir do capítulo",
"book_detail_to_chapter": "Até o capítulo (opcional)",
"book_detail_range_queuing": "Na fila…",
"book_detail_scrape_range": "Intervalo de extração",
"book_detail_admin": "Admin",
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
"book_detail_scraping_home": "← Início",
"book_detail_rescrape_book": "Reextrair livro",
"book_detail_less": "Menos",
"book_detail_more": "Mais",
"chapters_search_placeholder": "Pesquisar capítulos…",
"chapters_jump_to": "Ir para Cap.{n}",
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
"chapters_none_available": "Nenhum capítulo disponível ainda.",
"chapters_reading_indicator": "lendo",
"chapters_result_count": "{n} resultados",
"reader_fetching_chapter": "Buscando capítulo…",
"reader_words": "{n} palavras",
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
"profile_click_to_change": "Clique no avatar para mudar a foto",
"profile_tts_voice": "Voz TTS",
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
"profile_saving": "Salvando…",
"profile_saved": "Salvo!",
"profile_session_this": "Esta sessão",
"profile_session_signed_in": "Entrou em {date}",
"profile_session_last_seen": "· Visto por último em {date}",
"profile_session_sign_out": "Sair",
"profile_session_end": "Encerrar",
"profile_session_unrecognised": "Estes são todos os dispositivos conectados à sua conta. Encerre qualquer sessão que não reconhecer.",
"profile_no_sessions": "Nenhum registro de sessão encontrado. As sessões são rastreadas a partir do próximo login.",
"profile_change_password_heading": "Mudar senha",
"profile_update_password": "Atualizar senha",
"profile_updating": "Atualizando…",
"profile_password_changed_ok": "Senha alterada com sucesso.",
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
"profile_subscription_heading": "Assinatura",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuito",
"profile_pro_active": "Sua assinatura Pro está ativa.",
"profile_pro_perks": "Áudio ilimitado, todos os idiomas de tradução e seleção de voz estão habilitados.",
"profile_manage_subscription": "Gerenciar assinatura",
"profile_upgrade_heading": "Assinar o Pro",
"profile_upgrade_desc": "Desbloqueie áudio ilimitado, traduções em 4 idiomas e seleção de voz.",
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
"user_joined": "Entrou em {date}",
"user_followers_label": "seguidores",
"user_following_label": "seguindo",
"user_no_books": "Nenhum livro na biblioteca ainda.",
"admin_pages_label": "Páginas",
"admin_tools_label": "Ferramentas",
"admin_scrape_status_idle": "Ocioso",
"admin_scrape_full_catalogue": "Catálogo completo",
"admin_scrape_single_book": "Livro único",
"admin_scrape_quick_genres": "Gêneros rápidos",
"admin_scrape_task_history": "Histórico de tarefas",
"admin_scrape_filter_placeholder": "Filtrar por tipo, status ou URL…",
"admin_scrape_no_matching": "Nenhuma tarefa correspondente.",
"admin_scrape_start": "Iniciar extração",
"admin_scrape_queuing": "Na fila…",
"admin_scrape_running": "Executando…",
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
"admin_audio_cache_empty": "Cache de áudio vazio.",
"admin_audio_no_cache_results": "Sem resultados.",
"admin_changelog_gitea": "Releases do Gitea",
"admin_changelog_no_releases": "Nenhum release encontrado.",
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
"comments_top": "Mais votados",
"comments_new": "Novos",
"comments_posting": "Publicando…",
"comments_login_link": "Entre",
"comments_login_suffix": "para deixar um comentário.",
"comments_anonymous": "Anônimo",
"reader_audio_narration": "Narração em Áudio",
"reader_playing": "Reproduzindo — controles abaixo",
"reader_paused": "Pausado — controles abaixo",
"reader_ch_ready": "Cap.{n} pronto",
"reader_ch_preparing": "Preparando Cap.{n}… {percent}%",
"reader_ch_generate_on_nav": "Cap.{n} será gerado ao navegar",
"reader_now_playing": "Reproduzindo: {title}",
"reader_load_this_chapter": "Carregar este capítulo",
"reader_generate_samples": "Gerar amostras ausentes",
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
"reader_choose_voice": "Escolher Voz",
"reader_generating_narration": "Gerando narração…",
"profile_font_family": "Fonte",
"profile_font_system": "Sistema",
"profile_font_serif": "Serif",
"profile_font_mono": "Mono",
"profile_text_size": "Tamanho do texto",
"profile_text_size_sm": "Pequeno",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande"
}

409
ui/messages/ru.json Normal file
View File

@@ -0,0 +1,409 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feedback": "Обратная связь",
"nav_admin": "Админ",
"nav_profile": "Профиль",
"nav_sign_in": "Войти",
"nav_sign_out": "Выйти",
"nav_toggle_menu": "Меню",
"nav_admin_panel": "Панель администратора",
"footer_library": "Библиотека",
"footer_catalogue": "Каталог",
"footer_feedback": "Обратная связь",
"footer_disclaimer": "Отказ от ответственности",
"footer_privacy": "Конфиденциальность",
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Книги",
"home_stat_chapters": "Главы",
"home_stat_in_progress": "В процессе",
"home_continue_reading": "Продолжить чтение",
"home_view_all": "Смотреть все",
"home_recently_updated": "Недавно обновлённые",
"home_from_following": "От авторов, на которых вы подписаны",
"home_empty_title": "Ваша библиотека пуста",
"home_empty_body": "Откройте для себя новеллы и добавьте их в библиотеку.",
"home_discover_novels": "Открыть новеллы",
"home_via_reader": "от {username}",
"home_chapter_badge": "гл.{n}",
"player_generating": "Генерация… {percent}%",
"player_loading": "Загрузка…",
"player_chapters": "Главы",
"player_chapter_n": "Глава {n}",
"player_toggle_chapter_list": "Список глав",
"player_chapter_list_label": "Список глав",
"player_close_chapter_list": "Закрыть список глав",
"player_rewind_15": "Назад 15 секунд",
"player_skip_30": "Вперёд 30 секунд",
"player_back_15": "15 сек",
"player_forward_30": "+30 сек",
"player_play": "Воспроизвести",
"player_pause": "Пауза",
"player_speed_label": "Скорость {speed}x",
"player_seek_label": "Прогресс главы",
"player_change_speed": "Изменить скорость",
"player_auto_next_on": "Автопереход вкл.",
"player_auto_next_off": "Автопереход выкл.",
"player_auto_next_ready": "Автопереход — гл.{n} готова",
"player_auto_next_preparing": "Автопереход — подготовка гл.{n}…",
"player_auto_next_aria": "Автопереход {state}",
"player_go_to_chapter": "Перейти к главе",
"player_close": "Закрыть плеер",
"login_page_title": "Вход — libnovel",
"login_heading": "Войти в libnovel",
"login_subheading": "Выберите провайдера для входа",
"login_continue_google": "Продолжить с Google",
"login_continue_github": "Продолжить с GitHub",
"login_terms_notice": "Входя, вы принимаете наши условия использования.",
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
"books_page_title": "Библиотека — libnovel",
"books_heading": "Ваша библиотека",
"books_empty_title": "Книг пока нет",
"books_empty_body": "Добавляйте книги в библиотеку, посещая страницы книг.",
"books_browse_catalogue": "Обзор каталога",
"books_chapter_count": "{n} глав",
"books_last_read": "Последнее: гл.{n}",
"books_reading_progress": "Гл.{current} / {total}",
"books_remove": "Удалить",
"catalogue_page_title": "Каталог — libnovel",
"catalogue_heading": "Каталог",
"catalogue_search_placeholder": "Поиск новелл…",
"catalogue_filter_genre": "Жанр",
"catalogue_filter_status": "Статус",
"catalogue_filter_sort": "Сортировка",
"catalogue_sort_popular": "Популярные",
"catalogue_sort_new": "Новые",
"catalogue_sort_top_rated": "Топ по рейтингу",
"catalogue_sort_rank": "По рангу",
"catalogue_status_all": "Все",
"catalogue_status_ongoing": "Продолжаются",
"catalogue_status_completed": "Завершены",
"catalogue_genre_all": "Все жанры",
"catalogue_clear_filters": "Сбросить",
"catalogue_reset": "Сброс",
"catalogue_no_results": "Новеллы не найдены.",
"catalogue_loading": "Загрузка…",
"catalogue_load_more": "Загрузить ещё",
"catalogue_results_count": "{n} результатов",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
"book_detail_add_to_library": "В библиотеку",
"book_detail_remove_from_library": "Удалить из библиотеки",
"book_detail_read_now": "Читать",
"book_detail_continue_reading": "Продолжить чтение",
"book_detail_start_reading": "Начать чтение",
"book_detail_chapters": "{n} глав",
"book_detail_status": "Статус",
"book_detail_author": "Автор",
"book_detail_genres": "Жанры",
"book_detail_description": "Описание",
"book_detail_source": "Источник",
"book_detail_rescrape": "Обновить",
"book_detail_scraping": "Обновление…",
"book_detail_in_library": "В библиотеке",
"chapters_page_title": "Главы — {title}",
"chapters_heading": "Главы",
"chapters_back_to_book": "К книге",
"chapters_reading_now": "Читается",
"chapters_empty": "Главы ещё не загружены.",
"reader_page_title": "{title} — Гл.{n} — libnovel",
"reader_play_narration": "Воспроизвести озвучку",
"reader_generating_audio": "Генерация аудио…",
"reader_signin_for_audio": "Доступна аудионарративация",
"reader_signin_audio_desc": "Войдите, чтобы слушать эту главу в озвучке ИИ.",
"reader_audio_error": "Ошибка генерации аудио.",
"reader_prev_chapter": "Предыдущая глава",
"reader_next_chapter": "Следующая глава",
"reader_back_to_chapters": "К главам",
"reader_chapter_n": "Глава {n}",
"reader_change_voice": "Сменить голос",
"reader_voice_panel_title": "Выбрать голос",
"reader_voice_kokoro": "Голоса Kokoro",
"reader_voice_pocket": "Голоса Pocket-TTS",
"reader_voice_play_sample": "Прослушать образец",
"reader_voice_stop_sample": "Остановить образец",
"reader_voice_selected": "Выбран",
"reader_close_voice_panel": "Закрыть панель голоса",
"reader_auto_next": "Автопереход",
"reader_speed": "Скорость",
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
"profile_page_title": "Профиль — libnovel",
"profile_heading": "Профиль",
"profile_avatar_label": "Аватар",
"profile_change_avatar": "Изменить аватар",
"profile_username": "Имя пользователя",
"profile_email": "Email",
"profile_change_password": "Изменить пароль",
"profile_current_password": "Текущий пароль",
"profile_new_password": "Новый пароль",
"profile_confirm_password": "Подтвердить пароль",
"profile_save_password": "Сохранить пароль",
"profile_appearance_heading": "Внешний вид",
"profile_theme_label": "Тема",
"profile_theme_amber": "Янтарь",
"profile_theme_slate": "Сланец",
"profile_theme_rose": "Роза",
"profile_reading_heading": "Настройки чтения",
"profile_voice_label": "Голос по умолчанию",
"profile_speed_label": "Скорость воспроизведения",
"profile_auto_next_label": "Автопереход к следующей главе",
"profile_save_settings": "Сохранить настройки",
"profile_settings_saved": "Настройки сохранены.",
"profile_settings_error": "Не удалось сохранить настройки.",
"profile_password_saved": "Пароль изменён.",
"profile_password_error": "Не удалось изменить пароль.",
"profile_sessions_heading": "Активные сессии",
"profile_sign_out_all": "Выйти на всех других устройствах",
"profile_joined": "Зарегистрирован {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Библиотека {username}",
"user_follow": "Подписаться",
"user_unfollow": "Отписаться",
"user_followers": "{n} подписчиков",
"user_following": "{n} подписок",
"user_library_empty": "В библиотеке нет книг.",
"error_not_found_title": "Страница не найдена",
"error_not_found_body": "Запрошенная страница не существует.",
"error_generic_title": "Что-то пошло не так",
"error_go_home": "На главную",
"error_status": "Ошибка {status}",
"admin_scrape_page_title": "Парсинг — Админ",
"admin_scrape_heading": "Парсинг",
"admin_scrape_catalogue": "Парсинг каталога",
"admin_scrape_book": "Парсинг книги",
"admin_scrape_url_placeholder": "URL книги на novelfire.net",
"admin_scrape_range": "Диапазон глав",
"admin_scrape_from": "От",
"admin_scrape_to": "До",
"admin_scrape_submit": "Парсить",
"admin_scrape_cancel": "Отмена",
"admin_scrape_status_pending": "Ожидание",
"admin_scrape_status_running": "Выполняется",
"admin_scrape_status_done": "Готово",
"admin_scrape_status_failed": "Ошибка",
"admin_scrape_status_cancelled": "Отменено",
"admin_tasks_heading": "Последние задачи",
"admin_tasks_empty": "Задач пока нет.",
"admin_audio_page_title": "Аудио — Админ",
"admin_audio_heading": "Аудио задачи",
"admin_audio_empty": "Аудио задач нет.",
"admin_changelog_page_title": "Changelog — Админ",
"admin_changelog_heading": "Changelog",
"comments_heading": "Комментарии",
"comments_empty": "Комментариев пока нет. Будьте первым!",
"comments_placeholder": "Написать комментарий…",
"comments_submit": "Отправить",
"comments_login_prompt": "Войдите, чтобы комментировать.",
"comments_vote_up": "Плюс",
"comments_vote_down": "Минус",
"comments_delete": "Удалить",
"comments_reply": "Ответить",
"comments_show_replies": "Показать {n} ответов",
"comments_hide_replies": "Скрыть ответы",
"comments_edited": "изменено",
"comments_deleted": "[удалено]",
"disclaimer_page_title": "Отказ от ответственности — libnovel",
"privacy_page_title": "Политика конфиденциальности — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Условия использования — libnovel",
"common_loading": "Загрузка…",
"common_error": "Ошибка",
"common_save": "Сохранить",
"common_cancel": "Отмена",
"common_close": "Закрыть",
"common_search": "Поиск",
"common_back": "Назад",
"common_next": "Далее",
"common_previous": "Назад",
"common_yes": "Да",
"common_no": "Нет",
"common_on": "вкл.",
"common_off": "выкл.",
"locale_switcher_label": "Язык",
"books_empty_library": "Ваша библиотека пуста.",
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
"books_empty_discover_link": "Каталога",
"books_empty_discover_suffix": "появятся здесь.",
"books_count": "{n} книг{s}",
"catalogue_sort_updated": "По дате обновления",
"catalogue_search_button": "Поиск",
"catalogue_refresh": "Обновить",
"catalogue_refreshing": "В очереди…",
"catalogue_refresh_mobile": "Обновить каталог",
"catalogue_all_loaded": "Все новеллы загружены",
"catalogue_scroll_top": "Вверх",
"catalogue_view_grid": "Сетка",
"catalogue_view_list": "Список",
"catalogue_browse_source": "Смотреть новеллы с novelfire.net",
"catalogue_search_results": "{n} результат{s} по запросу «{q}»",
"catalogue_search_local_count": "({local} локальных, {remote} с novelfire)",
"catalogue_rank_ranked": "{n} новелл отсортированы по последнему парсингу каталога",
"catalogue_rank_no_data": "Нет данных рейтинга.",
"catalogue_rank_no_data_body": "Нет данных рейтинга — запустите полный парсинг каталога для заполнения",
"catalogue_rank_run_scrape_admin": "Нажмите «Обновить каталог» выше, чтобы запустить полный парсинг.",
"catalogue_rank_run_scrape_user": "Попросите администратора запустить парсинг каталога.",
"catalogue_scrape_queued_flash": "Полный парсинг каталога поставлен в очередь. Библиотека и рейтинг обновятся по мере обработки.",
"catalogue_scrape_busy_flash": "Парсинг уже запущен. Проверьте позже.",
"catalogue_scrape_error_flash": "Не удалось поставить парсинг в очередь. Проверьте доступность сервиса.",
"catalogue_filters_label": "Фильтры",
"catalogue_apply": "Применить",
"catalogue_filter_rank_note": "Фильтры по жанру и статусу применяются только к разделу «Обзор»",
"catalogue_no_results_search": "Ничего не найдено.",
"catalogue_no_results_try": "Попробуйте другой запрос.",
"catalogue_no_results_filters": "Попробуйте другие фильтры или проверьте позже.",
"catalogue_scrape_queued_badge": "В очереди",
"catalogue_scrape_busy_badge": "Парсер занят",
"catalogue_scrape_busy_list": "Занят",
"catalogue_scrape_forbidden_badge": "Запрещено",
"catalogue_scrape_novel_button": "Парсить",
"catalogue_scraping_novel": "Парсинг…",
"book_detail_not_in_library": "не в библиотеке",
"book_detail_continue_ch": "Продолжить гл.{n}",
"book_detail_start_ch1": "Начать с гл.1",
"book_detail_preview_ch1": "Предпросмотр гл.1",
"book_detail_reading_ch": "Читается гл.{n} из {total}",
"book_detail_n_chapters": "{n} глав",
"book_detail_rescraping": "В очереди…",
"book_detail_from_chapter": "С главы",
"book_detail_to_chapter": "До главы (необязательно)",
"book_detail_range_queuing": "В очереди…",
"book_detail_scrape_range": "Диапазон глав",
"book_detail_admin": "Администрирование",
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
"book_detail_scraping_home": "← На главную",
"book_detail_rescrape_book": "Перепарсить книгу",
"book_detail_less": "Скрыть",
"book_detail_more": "Ещё",
"chapters_search_placeholder": "Поиск глав…",
"chapters_jump_to": "Перейти к гл.{n}",
"chapters_no_match": "Главы по запросу «{q}» не найдены",
"chapters_none_available": "Глав пока нет.",
"chapters_reading_indicator": "читается",
"chapters_result_count": "{n} результатов",
"reader_fetching_chapter": "Загрузка главы…",
"reader_words": "{n} слов",
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
"profile_click_to_change": "Нажмите на аватар для смены фото",
"profile_tts_voice": "Голос TTS",
"profile_auto_advance": "Автопереход к следующей главе",
"profile_saving": "Сохранение…",
"profile_saved": "Сохранено!",
"profile_session_this": "Текущая сессия",
"profile_session_signed_in": "Вход {date}",
"profile_session_last_seen": "· Последний визит {date}",
"profile_session_sign_out": "Выйти",
"profile_session_end": "Завершить",
"profile_session_unrecognised": "Это все устройства, авторизованные в вашем аккаунте. Завершите любую сессию, которую не узнаёте.",
"profile_no_sessions": "Записей сессий нет. Отслеживание начнётся со следующего входа.",
"profile_change_password_heading": "Изменить пароль",
"profile_update_password": "Обновить пароль",
"profile_updating": "Обновление…",
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
"profile_pro_active": "Ваша подписка Pro активна.",
"profile_pro_perks": "Безлимитное аудио, все языки перевода и выбор голоса доступны.",
"profile_manage_subscription": "Управление подпиской",
"profile_upgrade_heading": "Перейти на Pro",
"profile_upgrade_desc": "Разблокируйте безлимитное аудио, переводы на 4 языка и выбор голоса.",
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
"user_joined": "Зарегистрирован {date}",
"user_followers_label": "подписчиков",
"user_following_label": "подписок",
"user_no_books": "Книг в библиотеке пока нет.",
"admin_pages_label": "Страницы",
"admin_tools_label": "Инструменты",
"admin_scrape_status_idle": "Ожидание",
"admin_scrape_full_catalogue": "Полный каталог",
"admin_scrape_single_book": "Одна книга",
"admin_scrape_quick_genres": "Быстрые жанры",
"admin_scrape_task_history": "История задач",
"admin_scrape_filter_placeholder": "Фильтр по типу, статусу или URL…",
"admin_scrape_no_matching": "Задач не найдено.",
"admin_scrape_start": "Начать парсинг",
"admin_scrape_queuing": "В очереди…",
"admin_scrape_running": "Выполняется…",
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
"admin_audio_no_matching_jobs": "Заданий не найдено.",
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
"admin_audio_cache_empty": "Аудиокэш пуст.",
"admin_audio_no_cache_results": "Результатов нет.",
"admin_changelog_gitea": "Релизы Gitea",
"admin_changelog_no_releases": "Релизов не найдено.",
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
"comments_top": "Лучшие",
"comments_new": "Новые",
"comments_posting": "Отправка…",
"comments_login_link": "Войдите",
"comments_login_suffix": "чтобы оставить комментарий.",
"comments_anonymous": "Аноним",
"reader_audio_narration": "Аудионарратив",
"reader_playing": "Воспроизводится — управление ниже",
"reader_paused": "Пауза — управление ниже",
"reader_ch_ready": "Гл.{n} готова",
"reader_ch_preparing": "Подготовка гл.{n}… {percent}%",
"reader_ch_generate_on_nav": "Гл.{n} сгенерируется при переходе",
"reader_now_playing": "Сейчас играет: {title}",
"reader_load_this_chapter": "Загрузить эту главу",
"reader_generate_samples": "Сгенерировать недостающие образцы",
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
"profile_font_mono": "Моноширинный",
"profile_text_size": "Размер текста",
"profile_text_size_sm": "Маленький",
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой"
}

222
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1", "@opentelemetry/resources": "^2.6.1",
@@ -1719,6 +1720,49 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@inlang/paraglide-js": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.15.1.tgz",
"integrity": "sha512-7wWKbLWwLx1dkkYz55TnVp+39atKXf7rnlHnL8adSmM73UaAdB9fXDzo24GHSY/6FPGFKSkgHdT2qyJv2whWsA==",
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "^0.2.1",
"@inlang/sdk": "^2.9.1",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
"unplugin": "^2.1.2",
"urlpattern-polyfill": "^10.0.0"
},
"bin": {
"paraglide-js": "bin/run.js"
}
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.3"
}
},
"node_modules/@inlang/sdk": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.1.tgz",
"integrity": "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg==",
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.9",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^13.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -1780,6 +1824,43 @@
"url": "https://opencollective.com/js-sdsl" "url": "https://opencollective.com/js-sdsl"
} }
}, },
"node_modules/@lix-js/sdk": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.9.tgz",
"integrity": "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A==",
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-protocol-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@lix-js/sdk/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
"license": "Apache-2.0"
},
"node_modules/@opentelemetry/api": { "node_modules/@opentelemetry/api": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -4135,6 +4216,12 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/@sinclair/typebox": {
"version": "0.31.28",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
"license": "MIT"
},
"node_modules/@smithy/abort-controller": { "node_modules/@smithy/abort-controller": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz",
@@ -4867,6 +4954,15 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.48.0-build4",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5405,6 +5501,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"license": "MIT"
},
"node_modules/ast-types": { "node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -5590,6 +5692,28 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/comment-json": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -5597,6 +5721,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5635,6 +5768,20 @@
} }
} }
}, },
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5966,6 +6113,15 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/human-id": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz",
"integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==",
"license": "MIT",
"bin": {
"human-id": "dist/cli.js"
}
},
"node_modules/import-in-the-middle": { "node_modules/import-in-the-middle": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz",
@@ -6062,6 +6218,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6101,6 +6263,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/kysely": {
"version": "0.28.14",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz",
"integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.31.1", "version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -6988,6 +7159,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sqlite-wasm-kysely": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
},
"peerDependencies": {
"kysely": "*"
}
},
"node_modules/standard-as-callback": { "node_modules/standard-as-callback": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -7201,6 +7383,21 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -7231,6 +7428,25 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/urlpattern-polyfill": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -7330,6 +7546,12 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/whatwg-url": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -7,7 +7,7 @@
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
@@ -30,6 +30,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1", "@opentelemetry/resources": "^2.6.1",

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "ru", "id", "pt-BR", "fr"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -8,6 +8,51 @@
--color-surface-3: #3f3f46; /* zinc-700 */ --color-surface-3: #3f3f46; /* zinc-700 */
--color-muted: #a1a1aa; /* zinc-400 */ --color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */ --color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f3f46; /* zinc-700 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
[data-theme="amber"] {
--color-brand: #f59e0b;
--color-brand-dim: #d97706;
--color-surface: #18181b;
--color-surface-2: #27272a;
--color-surface-3: #3f3f46;
--color-muted: #a1a1aa;
--color-text: #f4f4f5;
--color-border: #3f3f46;
--color-danger: #f87171;
--color-success: #4ade80;
}
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
[data-theme="slate"] {
--color-brand: #818cf8; /* indigo-400 */
--color-brand-dim: #4f46e5; /* indigo-600 */
--color-surface: #0f172a; /* slate-900 */
--color-surface-2: #1e293b; /* slate-800 */
--color-surface-3: #334155; /* slate-700 */
--color-muted: #94a3b8; /* slate-400 */
--color-text: #f1f5f9; /* slate-100 */
--color-border: #334155; /* slate-700 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
[data-theme="rose"] {
--color-brand: #fb7185; /* rose-400 */
--color-brand-dim: #e11d48; /* rose-600 */
--color-surface: #18181b; /* zinc-900 */
--color-surface-2: #1c1318; /* custom dark rose */
--color-surface-3: #2d1f26; /* custom dark rose-2 */
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f2d36; /* custom rose border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
} }
html { html {
@@ -15,18 +60,25 @@ html {
color: var(--color-text); color: var(--color-text);
} }
/* ── Reading typography custom properties ──────────────────────────── */
:root {
--reading-font: system-ui, -apple-system, sans-serif;
--reading-size: 1.05rem;
}
/* ── Chapter prose ─────────────────────────────────────────────────── */ /* ── Chapter prose ─────────────────────────────────────────────────── */
.prose-chapter { .prose-chapter {
max-width: 72ch; max-width: 72ch;
line-height: 1.85; line-height: 1.85;
font-size: 1.05rem; font-family: var(--reading-font);
color: #d4d4d8; /* zinc-300 */ font-size: var(--reading-size);
color: var(--color-muted);
} }
.prose-chapter h1, .prose-chapter h1,
.prose-chapter h2, .prose-chapter h2,
.prose-chapter h3 { .prose-chapter h3 {
color: #f4f4f5; color: var(--color-text);
font-weight: 700; font-weight: 700;
margin-top: 1.5em; margin-top: 1.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@@ -41,15 +93,15 @@ html {
} }
.prose-chapter em { .prose-chapter em {
color: #a1a1aa; color: var(--color-muted);
} }
.prose-chapter strong { .prose-chapter strong {
color: #f4f4f5; color: var(--color-text);
} }
.prose-chapter hr { .prose-chapter hr {
border-color: #3f3f46; border-color: var(--color-border);
margin: 2em 0; margin: 2em 0;
} }
@@ -62,4 +114,3 @@ html {
.animate-progress-bar { .animate-progress-bar {
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards; animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
} }

2
ui/src/app.d.ts vendored
View File

@@ -6,9 +6,11 @@ declare global {
interface Locals { interface Locals {
sessionId: string; sessionId: string;
user: { id: string; username: string; role: string; authSessionId: string } | null; user: { id: string; username: string; role: string; authSessionId: string } | null;
isPro: boolean;
} }
interface PageData { interface PageData {
user?: { id: string; username: string; role: string; authSessionId: string } | null; user?: { id: string; username: string; role: string; authSessionId: string } | null;
isPro?: boolean;
} }
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="%lang%" dir="%dir%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -1,11 +1,12 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { handleErrorWithSentry } from '@sentry/sveltekit'; import { handleErrorWithSentry } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit'; import * as Sentry from '@sentry/sveltekit';
import { randomBytes, createHmac } from 'node:crypto'; import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public'; import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger'; import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache'; import { drain as drainPresignCache } from '$lib/server/presignCache';
import { NodeSDK } from '@opentelemetry/sdk-node'; import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
@@ -13,6 +14,7 @@ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { resourceFromAttributes } from '@opentelemetry/resources'; import { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { paraglideMiddleware } from '$lib/paraglide/server';
// ─── OpenTelemetry server-side tracing + logs ───────────────────────────────── // ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
// No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev). // No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev).
@@ -138,7 +140,21 @@ export function parseAuthToken(token: string): { id: string; username: string; r
// ─── Hook ───────────────────────────────────────────────────────────────────── // ─── Hook ─────────────────────────────────────────────────────────────────────
export const handle: Handle = async ({ event, resolve }) => { function getTextDirection(locale: string): string {
// All supported locales (en, ru, id, pt-BR, fr) are LTR
return 'ltr';
}
const paraglideHandle: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
event.request = localizedRequest;
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale))
});
});
const appHandle: Handle = async ({ event, resolve }) => {
// During graceful shutdown, reject new requests immediately so the load // During graceful shutdown, reject new requests immediately so the load
// balancer / Docker health-check can drain existing connections. // balancer / Docker health-check can drain existing connections.
if (shuttingDown) { if (shuttingDown) {
@@ -194,6 +210,20 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null; event.locals.user = null;
} }
// ── isPro: read fresh from DB so role changes take effect without re-login ──
if (event.locals.user) {
try {
const dbUser = await getUserById(event.locals.user.id);
event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin';
} catch {
event.locals.isPro = false;
}
} else {
event.locals.isPro = false;
}
return resolve(event); return resolve(event);
}; };
export const handle = sequence(paraglideHandle, appHandle);

4
ui/src/hooks.ts Normal file
View File

@@ -0,0 +1,4 @@
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
export const reroute: Reroute = ({ url }) => deLocalizeUrl(url).pathname;

View File

@@ -51,6 +51,8 @@
import { audioStore } from '$lib/audio.svelte'; import { audioStore } from '$lib/audio.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
interface Props { interface Props {
slug: string; slug: string;
@@ -63,8 +65,10 @@
nextChapter?: number | null; nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */ /** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[]; chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */ /** List of available voices from the backend. */
voices?: string[]; voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
} }
let { let {
@@ -75,9 +79,14 @@
cover = '', cover = '',
nextChapter = null, nextChapter = null,
chapters = [], chapters = [],
voices = [] voices = [],
onProRequired = undefined
}: Props = $props(); }: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// ── Voice selector state ──────────────────────────────────────────────── // ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false); let showVoicePanel = $state(false);
/** Voice whose sample is currently being fetched or playing. */ /** Voice whose sample is currently being fetched or playing. */
@@ -86,10 +95,33 @@
let sampleAudio = $state<HTMLAudioElement | null>(null); let sampleAudio = $state<HTMLAudioElement | null>(null);
/** /**
* Human-readable label for a voice ID. * Human-readable label for a voice.
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)" * Kokoro: "af_bella" → "Bella (US F)"
* Pocket-TTS: "alba" → "Alba (EN F)"
* Falls back gracefully if called with a bare string (e.g. from the store default).
*/ */
function voiceLabel(v: string): string { function voiceLabel(v: Voice | string): string {
// Handle plain string IDs stored in audioStore.voice
if (typeof v === 'string') {
// Try to match against the voices list
const found = voices.find((x) => x.id === v);
if (found) return voiceLabel(found);
// Bare kokoro ID fallback (legacy / default "af_bella")
return kokoroLabelFromId(v);
}
if (v.engine === 'pocket-tts') {
const langLabel = v.lang.toUpperCase().replace('-', '');
const genderLabel = v.gender.toUpperCase();
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return `${name} (${langLabel} ${genderLabel})`;
}
// Kokoro
return kokoroLabelFromId(v.id);
}
function kokoroLabelFromId(id: string): string {
const langMap: Record<string, string> = { const langMap: Record<string, string> = {
af: 'US', am: 'US', af: 'US', am: 'US',
bf: 'UK', bm: 'UK', bf: 'UK', bm: 'UK',
@@ -112,9 +144,8 @@
pf: 'F', pm: 'M', pf: 'F', pm: 'M',
zf: 'F', zm: 'M', zf: 'F', zm: 'M',
}; };
const prefix = v.slice(0, 2); const prefix = id.slice(0, 2);
const name = v.slice(3); const name = id.slice(3);
// Capitalise and strip legacy v0 prefix.
const displayName = name const displayName = name
.replace(/^v0/, '') .replace(/^v0/, '')
.replace(/^([a-z])/, (c: string) => c.toUpperCase()); .replace(/^([a-z])/, (c: string) => c.toUpperCase());
@@ -316,23 +347,28 @@
// ── API helpers ──────────────────────────────────────────────────────────── // ── API helpers ────────────────────────────────────────────────────────────
type PresignResult =
| { ready: true; url: string }
| { ready: false; enqueued: boolean }; // enqueued=true → presign already POSTed
async function tryPresign( async function tryPresign(
targetSlug: string, targetSlug: string,
targetChapter: number, targetChapter: number,
targetVoice: string targetVoice: string
): Promise<string | null> { ): Promise<PresignResult> {
const params = new URLSearchParams({ const params = new URLSearchParams({
slug: targetSlug, slug: targetSlug,
n: String(targetChapter), n: String(targetChapter),
voice: targetVoice voice: targetVoice
}); });
const res = await fetch(`/api/presign/audio?${params}`); const res = await fetch(`/api/presign/audio?${params}`);
// 202: TTS was just enqueued by the presign endpoint — audio not ready yet. // 202: presign endpoint already triggered TTS — skip the POST, go straight to polling.
// 404: legacy fallback (should no longer occur after endpoint change). // 404: legacy fallback (should no longer occur after endpoint change).
if (res.status === 202 || res.status === 404) return null; if (res.status === 202) return { ready: false, enqueued: true };
if (res.status === 404) return { ready: false, enqueued: false };
if (!res.ok) throw new Error(`presign HTTP ${res.status}`); if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
const data = (await res.json()) as { url: string }; const data = (await res.json()) as { url: string };
return data.url; return { ready: true, url: data.url };
} }
type AudioStatusResponse = type AudioStatusResponse =
@@ -394,50 +430,52 @@
try { try {
// Fast path: already generated // Fast path: already generated
const url = await tryPresign(slug, nextChapter, voice); const presignResult = await tryPresign(slug, nextChapter, voice);
if (url) { if (presignResult.ready) {
stopNextProgress(); stopNextProgress();
audioStore.nextProgress = 100; audioStore.nextProgress = 100;
audioStore.nextAudioUrl = url; audioStore.nextAudioUrl = presignResult.url;
audioStore.nextStatus = 'prefetched'; audioStore.nextStatus = 'prefetched';
return; return;
} }
// Slow path: trigger Kokoro generation (non-blocking POST), then poll. // Slow path: trigger generation (or skip POST if presign already enqueued).
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, { if (!presignResult.enqueued) {
method: 'POST', const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ voice }) headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ voice })
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`); });
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// Whether the server returned 200 (already cached) or 202 (enqueued), if (res.status === 200) {
// always presign — the status endpoint no longer returns a proxy URL. // Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
if (res.status === 200) { await res.body?.cancel();
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it. stopNextProgress();
await res.body?.cancel(); audioStore.nextProgress = 100;
} const doneUrl = await tryPresign(slug, nextChapter, voice);
// else 202: generation enqueued — fall through to poll. if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
audioStore.nextAudioUrl = doneUrl.url;
if (res.status !== 200) { audioStore.nextStatus = 'prefetched';
// 202: poll until done. return;
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
} }
} else { // 202: generation enqueued — fall through to poll.
stopNextProgress(); }
audioStore.nextProgress = 100;
// Poll until done (covers both: presign-enqueued and POST-enqueued paths).
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
} }
// Audio is ready in MinIO — get a direct presigned URL. // Audio is ready in MinIO — get a direct presigned URL.
const doneUrl = await tryPresign(slug, nextChapter, voice); const doneUrl = await tryPresign(slug, nextChapter, voice);
if (!doneUrl) throw new Error('Prefetch: audio done but presign returned 404'); if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
audioStore.nextAudioUrl = doneUrl; audioStore.nextAudioUrl = doneUrl.url;
audioStore.nextStatus = 'prefetched'; audioStore.nextStatus = 'prefetched';
} catch { } catch {
stopNextProgress(); stopNextProgress();
@@ -505,9 +543,9 @@
} }
// Fast path B: audio already in MinIO (presign check). // Fast path B: audio already in MinIO (presign check).
const url = await tryPresign(slug, chapter, voice); const presignResult = await tryPresign(slug, chapter, voice);
if (url) { if (presignResult.ready) {
audioStore.audioUrl = url; audioStore.audioUrl = presignResult.url;
audioStore.status = 'ready'; audioStore.status = 'ready';
// Restore last saved position after the audio element loads // Restore last saved position after the audio element loads
restoreSavedAudioTime(); restoreSavedAudioTime();
@@ -520,33 +558,53 @@
audioStore.status = 'generating'; audioStore.status = 'generating';
startProgress(); startProgress();
const res = await fetch(`/api/audio/${slug}/${chapter}`, { // presignResult.enqueued=true means /api/presign/audio already POSTed on our
method: 'POST', // behalf — skip the duplicate POST and go straight to polling.
headers: { 'Content-Type': 'application/json' }, if (!presignResult.enqueued) {
body: JSON.stringify({ voice }) const res = await fetch(`/api/audio/${slug}/${chapter}`, {
}); method: 'POST',
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (res.status !== 200) { if (res.status === 402) {
// 202: generation enqueued — poll until done. // Free daily limit reached — surface upgrade CTA
const final = await pollAudioStatus(slug, chapter, voice); audioStore.status = 'idle';
stopProgress();
if (final.status === 'failed') { onProRequired?.();
throw new Error( return;
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
} }
} else {
// 200: already cached — body is { status: 'done' }, no url needed. if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
await res.body?.cancel();
if (res.status === 200) {
// Already cached — body is { status: 'done' }, no url needed.
await res.body?.cancel();
await finishProgress();
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// 202: fall through to polling below.
}
// Poll until the runner finishes generating.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
} }
await finishProgress(); await finishProgress();
// Audio is ready in MinIO — always use a presigned URL for direct playback. // Audio is ready in MinIO — always use a presigned URL for direct playback.
const doneUrl = await tryPresign(slug, chapter, voice); const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl) throw new Error('Audio generated but presign returned 404'); if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl; audioStore.audioUrl = doneUrl.url;
audioStore.status = 'ready'; audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0 // Don't restore time for freshly generated audio — position is 0
// Immediately start pre-generating the next chapter in background. // Immediately start pre-generating the next chapter in background.
@@ -627,13 +685,59 @@
<svelte:window onkeydown={handleKeyDown} /> <svelte:window onkeydown={handleKeyDown} />
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700"> <!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
{#snippet voiceRow(v: import('$lib/types').Voice)}
<div
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v.id)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v.id)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v.id}
<svg class="w-3.5 h-3.5 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
{voiceLabel(v)}
</span>
<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>
<!-- Sample play button -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
>
{#if samplePlayingVoice === v.id}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</Button>
</div>
{/snippet}
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<div class="flex items-center justify-between gap-2 mb-3"> <div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/> <path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg> </svg>
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span> <span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
</div> </div>
<!-- Voice selector button --> <!-- Voice selector button -->
@@ -642,8 +746,8 @@
variant="ghost" variant="ghost"
size="sm" size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }} onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')} class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title="Change voice" title={m.reader_change_voice()}
> >
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
@@ -658,15 +762,15 @@
<!-- ── Voice selector panel ──────────────────────────────────────────── --> <!-- ── Voice selector panel ──────────────────────────────────────────── -->
{#if showVoicePanel && voices.length > 0} {#if showVoicePanel && voices.length > 0}
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden"> <div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between"> <div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span> <span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-6 w-6 text-zinc-500 hover:text-zinc-300" class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
onclick={() => { stopSample(); showVoicePanel = false; }} onclick={() => { stopSample(); showVoicePanel = false; }}
aria-label="Close voice selector" aria-label={m.reader_close_voice_panel()}
> >
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -674,63 +778,38 @@
</Button> </Button>
</div> </div>
<div class="max-h-64 overflow-y-auto"> <div class="max-h-64 overflow-y-auto">
{#each voices as v (v)} <!-- Kokoro (GPU) section -->
<div {#if kokoroVoices.length > 0}
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v && 'bg-amber-400/10')} <div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
role="button" <span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
tabindex="0"
onclick={() => selectVoice(v)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
{/if}
</div>
<!-- Voice name -->
<span class={cn('flex-1 text-xs', audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v}</span>
<!-- Sample play button (stop propagation so click doesn't select) -->
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
onclick={(e) => { e.stopPropagation(); playSample(v); }}
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
>
{#if samplePlayingVoice === v}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</Button>
</div> </div>
{/each} {#each kokoroVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
<!-- Pocket TTS (CPU) section -->
{#if pocketVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
</div>
{#each pocketVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
</div> </div>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50"> <div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-zinc-500"> <p class="text-xs text-(--color-muted)">
New voice applies on next "Play narration". {m.reader_voice_applies_next()}
{#if voices.length > 0} {#if voices.length > 0}
<a <a
href="/api/audio/voice-samples" href="/api/audio/voice-samples"
class="text-zinc-400 hover:text-amber-400 transition-colors underline" class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
onclick={(e) => { onclick={(e) => {
e.preventDefault(); e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {}); fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}} }}
>Generate missing samples</a> >{m.reader_generate_samples()}</a>
{/if} {/if}
</p> </p>
</div> </div>
@@ -742,14 +821,14 @@
{#if audioStore.status === 'idle' || audioStore.status === 'error'} {#if audioStore.status === 'idle' || audioStore.status === 'error'}
{#if audioStore.status === 'error'} {#if audioStore.status === 'error'}
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p> <p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if} {/if}
<Button variant="default" size="sm" onclick={handlePlay}> <Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
Play narration {m.reader_play_narration()}
</Button> </Button>
{:else if audioStore.status === 'loading'} {:else if audioStore.status === 'loading'}
<Button variant="default" size="sm" disabled> <Button variant="default" size="sm" disabled>
@@ -757,37 +836,37 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
Loading… {m.player_loading()}
</Button> </Button>
{:else if audioStore.status === 'generating'} {:else if audioStore.status === 'generating'}
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xs text-zinc-400">Generating narration</p> <p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden"> <div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
<div <div
class="h-full bg-amber-400 rounded-full transition-none" class="h-full bg-(--color-brand) rounded-full transition-none"
style="width: {audioStore.progress}%" style="width: {audioStore.progress}%"
></div> ></div>
</div> </div>
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p> <p class="text-xs text-(--color-muted) opacity-60 tabular-nums">{Math.round(audioStore.progress)}%</p>
</div> </div>
{:else if audioStore.status === 'ready'} {:else if audioStore.status === 'ready'}
<!-- Mini-bar is the canonical control surface — show a compact indicator here --> <!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400"> <div class="flex items-center gap-2 text-xs text-(--color-muted)">
{#if audioStore.isPlaying} {#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/> <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg> </svg>
<span>Playing — controls below</span> <span>{m.reader_playing()}</span>
{:else} {:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
<span>Paused — controls below</span> <span>{m.reader_paused()}</span>
{/if} {/if}
<span class="tabular-nums text-zinc-500"> <span class="tabular-nums text-(--color-muted) opacity-60">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span> </span>
</div> </div>
@@ -797,40 +876,40 @@
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')} class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)} onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'} title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
aria-pressed={audioStore.autoNext} aria-pressed={audioStore.autoNext}
> >
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/> <path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg> </svg>
Auto {m.reader_auto_next()}
</Button> </Button>
{/if} {/if}
</div> </div>
<!-- Next chapter pre-fetch status (only when auto-next is on) --> <!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined} {#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2"> <div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'} {#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-zinc-500"> <div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24"> <svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span> <span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
</div> </div>
{:else if audioStore.nextStatus === 'prefetched'} {:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1"> <p class="text-xs text-(--color-muted) flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg> </svg>
Ch.{nextChapter} ready {m.reader_ch_ready({ n: String(nextChapter) })}
</p> </p>
{:else if audioStore.nextStatus === 'failed'} {:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p> <p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
{/if} {/if}
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -838,11 +917,11 @@
{:else if audioStore.active} {:else if audioStore.active}
<!-- ── A different chapter is currently playing ── --> <!-- ── A different chapter is currently playing ── -->
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<p class="text-xs text-zinc-400"> <p class="text-xs text-(--color-muted)">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`} {m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
</p> </p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}> <Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
Load this chapter {m.reader_load_this_chapter()}
</Button> </Button>
</div> </div>
@@ -852,7 +931,7 @@
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
Play narration {m.reader_play_narration()}
</Button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -93,14 +93,14 @@
render the crop canvas outside the natural image bounds. The fixed render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. --> height gives cropperjs a stable container to size itself against. -->
<div class="px-5"> <div class="px-5">
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;"> <div class="rounded-xl bg-(--color-surface-2)" style="height: 300px; position: relative;">
<img <img
bind:this={imgEl} bind:this={imgEl}
alt="Crop preview" alt="Crop preview"
style="display:block; max-width:100%; max-height:100%;" style="display:block; max-width:100%; max-height:100%;"
/> />
</div> </div>
<p class="text-xs text-zinc-500 text-center mt-3"> <p class="text-xs text-(--color-muted) text-center mt-3">
Drag to reposition · pinch or scroll to zoom · drag corners to resize Drag to reposition · pinch or scroll to zoom · drag corners to resize
</p> </p>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { BookComment } from '$lib/types'; import type { BookComment } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
let { let {
slug, slug,
isLoggedIn = false, isLoggedIn = false,
@@ -243,28 +244,28 @@
<div class="mt-10"> <div class="mt-10">
<!-- Header + sort controls --> <!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap"> <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200"> <h2 class="text-base font-semibold text-(--color-text)">
Comments {m.comments_heading()}
{#if !loading && totalCount > 0} {#if !loading && totalCount > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span> <span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
{/if} {/if}
</h2> </h2>
<!-- Sort tabs --> <!-- Sort tabs -->
{#if !loading && comments.length > 0} {#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1"> <div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')} class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'top')} onclick={() => (sort = 'top')}
>Top</Button> >{m.comments_top()}</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')} class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => (sort = 'new')} onclick={() => (sort = 'new')}
>New</Button> >{m.comments_new()}</Button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -275,16 +276,16 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Textarea <Textarea
bind:value={newBody} bind:value={newBody}
placeholder="Write a comment…" placeholder={m.comments_placeholder()}
rows={3} rows={3}
/> />
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}> <span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{charCount}/2000 {charCount}/2000
</span> </span>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if postError} {#if postError}
<span class="text-xs text-red-400">{postError}</span> <span class="text-xs text-(--color-danger)">{postError}</span>
{/if} {/if}
<Button <Button
variant="default" variant="default"
@@ -292,15 +293,15 @@
disabled={posting || !newBody.trim() || charOver} disabled={posting || !newBody.trim() || charOver}
onclick={postComment} onclick={postComment}
> >
{posting ? 'Posting…' : 'Post'} {posting ? m.comments_posting() : m.comments_submit()}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{:else} {:else}
<p class="text-sm text-zinc-500"> <p class="text-sm text-(--color-muted)">
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a> <a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
to leave a comment. {m.comments_login_suffix()}
</p> </p>
{/if} {/if}
</div> </div>
@@ -309,17 +310,17 @@
{#if loading} {#if loading}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{#each Array(3) as _} {#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse"> <div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div> <div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div> <div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div> <div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div> </div>
{/each} {/each}
</div> </div>
{:else if loadError} {:else if loadError}
<p class="text-sm text-red-400">{loadError}</p> <p class="text-sm text-(--color-danger)">{loadError}</p>
{:else if comments.length === 0} {:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p> <p class="text-sm text-(--color-muted)">{m.comments_empty()}</p>
{:else} {:else}
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{#each comments as comment (comment.id)} {#each comments as comment (comment.id)}
@@ -328,39 +329,39 @@
{@const deleting = deletingIds.has(comment.id)} {@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id} {@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}"> <div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]} {#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" /> <img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
{:else} {:else}
<span class="text-sm font-medium text-zinc-400">Anonymous</span> <div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
</div>
{/if} {/if}
<span class="text-zinc-600 text-xs">&middot;</span> {#if comment.username}
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span> <a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
</div> {:else}
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
</div>
<!-- Body --> <!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p> <p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete --> <!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap"> <div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote --> <!-- Upvote -->
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')} class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting} disabled={voting}
onclick={() => vote(comment.id, 'up')} onclick={() => vote(comment.id, 'up')}
title="Upvote" title={m.comments_vote_up()}
> >
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/> <path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg> </svg>
@@ -368,14 +369,14 @@
</Button> </Button>
<!-- Downvote --> <!-- Downvote -->
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')} class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={voting} disabled={voting}
onclick={() => vote(comment.id, 'down')} onclick={() => vote(comment.id, 'down')}
title="Downvote" title={m.comments_vote_down()}
> >
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/> <path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg> </svg>
@@ -384,11 +385,11 @@
<!-- Reply button --> <!-- Reply button -->
{#if isLoggedIn} {#if isLoggedIn}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')} class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
onclick={() => { onclick={() => {
if (replyingTo === comment.id) { if (replyingTo === comment.id) {
replyingTo = null; replyingTo = null;
replyBody = ''; replyBody = '';
@@ -403,57 +404,57 @@
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/> <path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg> </svg>
Reply {m.comments_reply()}
</Button> </Button>
{/if} {/if}
<!-- Delete (owner only) --> <!-- Delete (owner only) -->
{#if isOwner} {#if isOwner}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto" class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting} disabled={deleting}
onclick={() => deleteComment(comment.id)} onclick={() => deleteComment(comment.id)}
title="Delete comment" title="Delete comment"
> >
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
Delete {m.comments_delete()}
</Button> </Button>
{/if} {/if}
</div> </div>
<!-- Inline reply form --> <!-- Inline reply form -->
{#if replyingTo === comment.id} {#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700"> <div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<Textarea <Textarea
bind:value={replyBody} bind:value={replyBody}
placeholder="Write a reply…" placeholder={m.comments_placeholder()}
rows={2} rows={2}
/> />
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}> <span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000 {replyCharCount}/2000
</span> </span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if replyError} {#if replyError}
<span class="text-xs text-red-400">{replyError}</span> <span class="text-xs text-(--color-danger)">{replyError}</span>
{/if} {/if}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="text-zinc-400 hover:text-zinc-200" class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }} onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button> >{m.common_cancel()}</Button>
<Button <Button
variant="default" variant="default"
size="sm" size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver} disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)} onclick={() => postReply(comment.id)}
> >
{replyPosting ? 'Posting…' : 'Reply'} {replyPosting ? m.comments_posting() : m.comments_reply()}
</Button> </Button>
</div> </div>
</div> </div>
@@ -462,59 +463,59 @@
<!-- Replies --> <!-- Replies -->
{#if comment.replies && comment.replies.length > 0} {#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60"> <div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
{#each comment.replies as reply (reply.id)} {#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]} {@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)} {@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)} {@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id} {@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}"> <div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header --> <!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]} {#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" /> <img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
{:else} {:else}
<span class="text-xs font-medium text-zinc-400">Anonymous</span> <div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
</div>
{/if} {/if}
<span class="text-zinc-600 text-xs">&middot;</span> {#if reply.username}
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span> <a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
</div> {:else}
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
{/if}
<span class="text-(--color-muted) opacity-60 text-xs">&middot;</span>
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
</div>
<!-- Reply body --> <!-- Reply body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p> <p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions --> <!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5"> <div class="flex items-center gap-3 pt-0.5">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')} class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting} disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)} onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote" title={m.comments_vote_up()}
> >
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/> <path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg> </svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span> <span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')} class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
disabled={replyVoting} disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)} onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote" title={m.comments_vote_down()}
> >
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/> <path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg> </svg>
@@ -522,19 +523,19 @@
</Button> </Button>
{#if replyIsOwner} {#if replyIsOwner}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto" class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting} disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)} onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply" title="Delete reply"
> >
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
Delete {m.comments_delete()}
</Button> </Button>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -16,10 +16,10 @@
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none'; 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
default: 'border-transparent bg-amber-400 text-zinc-900', default: 'border-transparent bg-(--color-brand) text-(--color-surface)',
secondary: 'border-transparent bg-zinc-700 text-zinc-200', secondary: 'border-transparent bg-(--color-surface-3) text-(--color-text)',
outline: 'border-zinc-600 text-zinc-300', outline: 'border-(--color-border) text-(--color-muted)',
destructive: 'border-transparent bg-red-500/20 text-red-400', destructive: 'border-transparent bg-(--color-danger)/20 text-(--color-danger)',
}; };
</script> </script>

View File

@@ -28,15 +28,15 @@
}: Props = $props(); }: Props = $props();
const base = const base =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50'; 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-surface) disabled:pointer-events-none disabled:opacity-50';
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300', default: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)',
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600', secondary: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-border)',
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100', outline: 'border border-(--color-border) bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100', ghost: 'bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300', destructive: 'bg-(--color-danger)/20 text-(--color-danger) hover:bg-(--color-danger)/30',
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto', link: 'text-(--color-brand) underline-offset-4 hover:underline bg-transparent p-0 h-auto',
}; };
const sizes: Record<Size, string> = { const sizes: Record<Size, string> = {

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props(); let { class: className = '', children }: Props = $props();
</script> </script>
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}> <div class={cn('rounded-xl border border-(--color-border) bg-(--color-surface-2)/50', className)}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props(); let { class: className = '', children }: Props = $props();
</script> </script>
<p class={cn('text-sm text-zinc-400', className)}> <p class={cn('text-sm text-(--color-muted)', className)}>
{@render children?.()} {@render children?.()}
</p> </p>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props(); let { class: className = '', children }: Props = $props();
</script> </script>
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}> <h3 class={cn('font-semibold leading-none tracking-tight text-(--color-text)', className)}>
{@render children?.()} {@render children?.()}
</h3> </h3>

View File

@@ -36,7 +36,7 @@
aria-modal="true" aria-modal="true"
onclick={handleBackdropClick} onclick={handleBackdropClick}
> >
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}> <div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props(); let { class: className = '', children }: Props = $props();
</script> </script>
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}> <h2 class={cn('text-base font-semibold leading-none tracking-tight text-(--color-text)', className)}>
{@render children?.()} {@render children?.()}
</h2> </h2>

View File

@@ -12,7 +12,7 @@
<div <div
role="separator" role="separator"
class={cn( class={cn(
'shrink-0 bg-zinc-700', 'shrink-0 bg-(--color-border)',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px', orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className className
)} )}

View File

@@ -30,8 +30,8 @@
{rows} {rows}
{disabled} {disabled}
class={cn( class={cn(
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors', 'flex w-full rounded-lg border border-(--color-border) bg-(--color-surface-2) px-3 py-2 text-sm text-(--color-text) placeholder-zinc-500 resize-none transition-colors',
'focus:outline-none focus:border-amber-400', 'focus:outline-none focus:border-(--color-brand)',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}

View File

@@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
} }
/** /**
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL. * Returns a presigned GET URL for a user's avatar from MinIO.
* Returns null if no avatar exists. * Returns null if no avatar object exists in MinIO for this user.
*/ */
export async function presignAvatarUrl(userId: string): Promise<string | null> { export async function presignAvatarUrl(userId: string): Promise<string | null> {
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`); const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
@@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
return data.url ? rewriteHost(data.url) : null; return data.url ? rewriteHost(data.url) : null;
} }
/**
* Resolves the best available avatar URL for a user.
*
* Priority:
* 1. MinIO — if the user has uploaded a custom avatar it will be found here
* (presigned, short-lived GET URL).
* 2. OAuth provider URL — stored in avatar_url when the account was created
* via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...).
* Returned as-is; the browser fetches it directly.
*
* Pass the raw `avatar_url` field from the PocketBase record as `storedValue`
* so this function can distinguish between a MinIO key and a remote URL without
* an extra DB round-trip.
*
* Returns null when neither source yields an avatar.
*/
export async function resolveAvatarUrl(
userId: string,
storedValue: string | null | undefined
): Promise<string | null> {
// 1. Try MinIO first (custom upload takes priority over OAuth picture).
try {
const minioUrl = await presignAvatarUrl(userId);
if (minioUrl) return minioUrl;
} catch {
// MinIO unavailable — fall through to OAuth fallback.
}
// 2. Fall back to OAuth-provided picture URL if it looks like a remote URL.
if (storedValue && storedValue.startsWith('http')) {
return storedValue;
}
return null;
}
/** /**
* Rewrites the MinIO host in a presigned URL to the public-facing URL. * Rewrites the MinIO host in a presigned URL to the public-facing URL.
* *

View File

@@ -54,6 +54,10 @@ export interface PBUserSettings {
auto_next: boolean; auto_next: boolean;
voice: string; voice: string;
speed: number; speed: number;
theme?: string;
locale?: string;
font_family?: string;
font_size?: number;
updated?: string; updated?: string;
} }
@@ -70,6 +74,8 @@ export interface User {
verification_token_exp?: string; verification_token_exp?: string;
oauth_provider?: string; oauth_provider?: string;
oauth_id?: string; oauth_id?: string;
polar_customer_id?: string;
polar_subscription_id?: string;
} }
// ─── Auth token cache ───────────────────────────────────────────────────────── // ─── Auth token cache ─────────────────────────────────────────────────────────
@@ -541,6 +547,19 @@ export async function getUserByUsername(username: string): Promise<User | null>
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`); return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
} }
/**
* Look up a user by their PocketBase record ID. Returns null if not found.
*/
export async function getUserById(id: string): Promise<User | null> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (res.status === 404) return null;
if (!res.ok) return null;
return res.json() as Promise<User>;
}
/** /**
* Look up a user by email. Returns null if not found. * Look up a user by email. Returns null if not found.
*/ */
@@ -558,6 +577,28 @@ export async function getUserByOAuth(provider: string, oauthId: string): Promise
); );
} }
/**
* Look up a user by their Polar customer ID. Returns null if not found.
*/
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
return listOne<User>(
'app_users',
`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
);
}
/**
* Patch arbitrary fields on an app_user record.
*/
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
throw new Error(`patchUser failed: ${res.status}${body}`);
}
}
/** /**
* Create a new user via OAuth (no password). email_verified is true since the * Create a new user via OAuth (no password). email_verified is true since the
* provider already verified it. Throws on DB errors. * provider already verified it. Throws on DB errors.
@@ -765,7 +806,7 @@ export async function getSettings(
export async function saveSettings( export async function saveSettings(
sessionId: string, sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number }, settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
userId?: string userId?: string
): Promise<void> { ): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>( const existing = await listOne<PBUserSettings & { id: string }>(
@@ -780,6 +821,10 @@ export async function saveSettings(
speed: settings.speed, speed: settings.speed,
updated: new Date().toISOString() updated: new Date().toISOString()
}; };
if (settings.theme !== undefined) payload.theme = settings.theme;
if (settings.locale !== undefined) payload.locale = settings.locale;
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
if (userId) payload.user_id = userId; if (userId) payload.user_id = userId;
if (existing) { if (existing) {
@@ -902,6 +947,24 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started'); return listAll<AudioJob>('audio_jobs', '', '-started');
} }
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {
id: string;
cache_key: string; // "slug/chapter/lang"
slug: string;
chapter: number;
lang: string;
status: string; // "pending" | "running" | "done" | "failed"
error_message: string;
started: string;
finished: string;
}
export async function listTranslationJobs(): Promise<TranslationJob[]> {
return listAll<TranslationJob>('translation_jobs', '', '-started');
}
export async function getAudioTime( export async function getAudioTime(
sessionId: string, sessionId: string,
slug: string, slug: string,

107
ui/src/lib/server/polar.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Polar.sh integration — server-side only.
*
* Responsibilities:
* - Verify webhook signatures (HMAC-SHA256)
* - Patch app_users.polar_customer_id / polar_subscription_id / role on subscription events
* - Expose isPro(userId) helper for gating
*
* Product IDs (Polar dashboard):
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
export const POLAR_PRO_PRODUCT_IDS = new Set([
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
'b6190307-79aa-4905-80c8-9ed941378d21' // annual
]);
// ─── Webhook signature verification ──────────────────────────────────────────
/**
* Verify the Polar webhook signature.
* Polar signs with HMAC-SHA256 over the raw body; header is "webhook-signature".
* Header format: "v1=<hex>" (may be comma-separated list of sigs)
*/
export function verifyPolarWebhook(rawBody: string, signatureHeader: string): boolean {
const secret = env.POLAR_WEBHOOK_SECRET;
if (!secret) {
log.warn('polar', 'POLAR_WEBHOOK_SECRET not set — rejecting webhook');
return false;
}
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const expectedBuf = Buffer.from(`v1=${expected}`);
// Header may contain multiple sigs separated by ", "
const sigs = signatureHeader.split(',').map((s) => s.trim());
for (const sig of sigs) {
try {
const sigBuf = Buffer.from(sig);
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
return true;
}
} catch {
// length mismatch etc — try next
}
}
return false;
}
// ─── Subscription event handler ───────────────────────────────────────────────
interface PolarSubscription {
id: string;
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
product_id: string;
customer_id: string;
customer_email?: string;
user_id?: string; // Polar user id (not our user id)
}
/**
* Handle a Polar subscription event.
* Finds the matching app_user by email and updates role + polar fields.
*/
export async function handleSubscriptionEvent(
eventType: string,
subscription: PolarSubscription
): Promise<void> {
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
if (!customer_email) {
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
return;
}
// Find user by their polar_customer_id first (faster on repeat events), then by email
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (!user) {
const { getUserByEmail } = await import('$lib/server/pocketbase');
user = await getUserByEmail(customer_email).catch(() => null);
}
if (!user) {
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
return;
}
const isProProduct = POLAR_PRO_PRODUCT_IDS.has(product_id);
const isActive = status === 'active';
const newRole = isProProduct && isActive ? 'pro' : (user.role === 'admin' ? 'admin' : 'user');
await patchUser(user.id, {
role: newRole,
polar_customer_id: customer_id,
polar_subscription_id: isActive ? subId : ''
});
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
}

View File

@@ -6,6 +6,20 @@
* safe to import in both server and client code. * safe to import in both server and client code.
*/ */
// ── Voice ─────────────────────────────────────────────────────────────────────
/** A single TTS voice returned by GET /api/voices. */
export interface Voice {
/** Voice identifier passed to TTS clients (e.g. "af_bella", "alba"). */
id: string;
/** TTS engine: "kokoro" | "pocket-tts". */
engine: string;
/** Primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr"). */
lang: string;
/** Gender: "f" | "m". */
gender: string;
}
// ── Comments ───────────────────────────────────────────────────────────────── // ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment { export interface BookComment {
@@ -27,4 +41,5 @@ export interface UserSettings {
voice: string; voice: string;
speed: number; speed: number;
autoNext: boolean; autoNext: boolean;
theme: string;
} }

View File

@@ -1,71 +1,59 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
const status = $derived(page.status); const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
const title = $derived( const title = $derived(
status === 404 status === 404
? 'Page not found' ? m.error_not_found_title()
: status === 403 : m.error_generic_title()
? 'Access denied'
: status === 429
? 'Too many requests'
: status >= 500
? 'Server error'
: 'Error'
); );
const description = $derived( const description = $derived(
status === 404 status === 404
? "The page you're looking for doesn't exist or has been moved." ? m.error_not_found_body()
: status === 403 : page.error?.message ?? m.error_generic_title()
? "You don't have permission to access this page."
: status === 429
? 'You are sending too many requests. Please slow down and try again shortly.'
: status >= 500
? 'An unexpected error occurred on our end. Try refreshing, or come back in a moment.'
: message
); );
const code = $derived(String(status)); const code = $derived(String(status));
</script> </script>
<svelte:head> <svelte:head>
<title>{status}{title} · libnovel</title> <title>{m.error_status({ status: code })} · libnovel</title>
</svelte:head> </svelte:head>
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte --> <!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
<div <div
class="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col items-center justify-center px-6 py-16 font-sans" class="min-h-screen bg-(--color-surface) text-(--color-text) flex flex-col items-center justify-center px-6 py-16 font-sans"
> >
<!-- Large status code --> <!-- Large status code -->
<p class="text-[8rem] sm:text-[11rem] font-black leading-none text-zinc-800 select-none tabular-nums"> <p class="text-[8rem] sm:text-[11rem] font-black leading-none bg-(--color-surface-2) select-none tabular-nums">
{code} {code}
</p> </p>
<!-- Title + description --> <!-- Title + description -->
<div class="mt-4 text-center max-w-md space-y-2"> <div class="mt-4 text-center max-w-md space-y-2">
<h1 class="text-2xl sm:text-3xl font-bold text-zinc-100">{title}</h1> <h1 class="text-2xl sm:text-3xl font-bold text-(--color-text)">{title}</h1>
<p class="text-zinc-400 text-sm sm:text-base leading-relaxed">{description}</p> <p class="text-(--color-muted) text-sm sm:text-base leading-relaxed">{description}</p>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="mt-10 flex flex-wrap gap-3 justify-center"> <div class="mt-10 flex flex-wrap gap-3 justify-center">
<a <a
href="/" href="/"
class="px-5 py-2.5 rounded-xl bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors" class="px-5 py-2.5 rounded-xl bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
> >
Go home {m.error_go_home()}
</a> </a>
<button <button
onclick={() => history.back()} onclick={() => history.back()}
class="px-5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-200 font-semibold text-sm hover:bg-zinc-700 transition-colors" class="px-5 py-2.5 rounded-xl bg-(--color-surface-2) border border-(--color-border) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors"
> >
Go back {m.common_back()}
</button> </button>
</div> </div>
<!-- Subtle branding --> <!-- Subtle branding -->
<p class="mt-16 text-xs text-zinc-700 tracking-widest uppercase select-none">libnovel</p> <p class="mt-16 text-xs text-(--color-muted) tracking-widest uppercase select-none">libnovel</p>
</div> </div>

View File

@@ -4,31 +4,57 @@ import { getSettings } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger'; import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in // Routes that are accessible without being logged in
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']); const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms', '/catalogue']);
export const load: LayoutServerLoad = async ({ locals, url }) => { export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
// Allow /auth/* (OAuth initiation + callbacks) without login // Allow public routes, /auth/*, and all book-browsing URLs (/books/[slug] and deeper)
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/'); // Note: /books (the personal library) is intentionally NOT public
const isPublic =
PUBLIC_ROUTES.has(url.pathname) ||
url.pathname.startsWith('/auth/') ||
url.pathname.startsWith('/books/');
if (!isPublic && !locals.user) { if (!isPublic && !locals.user) {
redirect(302, `/login`); redirect(302, `/login`);
} }
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 }; let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 };
try { try {
const row = await getSettings(locals.sessionId, locals.user?.id); const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) { if (row) {
settings = { settings = {
autoNext: row.auto_next ?? false, autoNext: row.auto_next ?? false,
voice: row.voice ?? 'af_bella', voice: row.voice ?? 'af_bella',
speed: row.speed ?? 1.0 speed: row.speed ?? 1.0,
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size ?? 1.0
}; };
} }
} catch (e) { } catch (e) {
log.warn('layout', 'failed to load settings', { err: String(e) }); log.warn('layout', 'failed to load settings', { err: String(e) });
} }
// If user is logged in and has a non-English locale saved, ensure the
// PARAGLIDE_LOCALE cookie is set so the locale persists after refresh.
if (locals.user) {
const savedLocale = settings.locale ?? 'en';
if (savedLocale !== 'en') {
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
if (currentCookieLocale !== savedLocale) {
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
path: '/',
maxAge: 34560000,
sameSite: 'lax',
httpOnly: false
});
}
}
}
return { return {
user: locals.user, user: locals.user,
isPro: locals.isPro,
settings settings
}; };
}; };

View File

@@ -2,18 +2,31 @@
import '../app.css'; import '../app.css';
import { page, navigating } from '$app/state'; import { page, navigating } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte'; import { audioStore } from '$lib/audio.svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
let { children, data }: { children: Snippet; data: LayoutData } = $props(); let { children, data }: { children: Snippet; data: LayoutData } = $props();
// Mobile nav drawer state // Mobile nav drawer state
let menuOpen = $state(false); let menuOpen = $state(false);
// Desktop dropdown menus
let userMenuOpen = $state(false);
let langMenuOpen = $state(false);
const THEMES = [
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
];
// Chapter list drawer state for the mini-player // Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false); let chapterDrawerOpen = $state(false);
@@ -21,24 +34,66 @@
// AudioPlayer components in chapter pages control it via audioStore. // AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null); let audioEl = $state<HTMLAudioElement | null>(null);
// ── Theme ──────────────────────────────────────────────────────────────
let currentTheme = $state(data.settings?.theme ?? 'amber');
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
// Expose theme + font state to child pages (e.g. profile picker)
setContext('theme', {
get current() { return currentTheme; },
set current(v: string) { currentTheme = v; },
get fontFamily() { return currentFontFamily; },
set fontFamily(v: string) { currentFontFamily = v; },
get fontSize() { return currentFontSize; },
set fontSize(v: number) { currentFontSize = v; }
});
$effect(() => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', currentTheme);
}
});
$effect(() => {
if (typeof document === 'undefined') return;
const fontMap: Record<string, string> = {
system: 'system-ui, -apple-system, sans-serif',
serif: "Georgia, 'Times New Roman', serif",
mono: "'Courier New', monospace",
};
document.documentElement.style.setProperty('--reading-font', fontMap[currentFontFamily] ?? fontMap.system);
document.documentElement.style.setProperty('--reading-size', `${currentFontSize}rem`);
});
// Apply persisted settings once on mount (server-loaded data). // Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false; let settingsApplied = false;
$effect(() => { $effect(() => {
if (!settingsApplied && data.settings) { if (data.settings) {
settingsApplied = true; if (!settingsApplied) {
audioStore.autoNext = data.settings.autoNext; settingsApplied = true;
audioStore.voice = data.settings.voice; audioStore.autoNext = data.settings.autoNext;
audioStore.speed = data.settings.speed; audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
} }
}); });
// ── Persist settings changes (debounced 800ms) ────────────────────────── // ── Persist settings changes (debounced 800ms) ──────────────────────────
let settingsSaveTimer = 0; let settingsSaveTimer = 0;
$effect(() => { $effect(() => {
// Subscribe to the three settings fields // Subscribe to settings fields
const autoNext = audioStore.autoNext; const autoNext = audioStore.autoNext;
const voice = audioStore.voice; const voice = audioStore.voice;
const speed = audioStore.speed; const speed = audioStore.speed;
const theme = currentTheme;
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server // Skip saving until settings have been applied from the server
if (!settingsApplied) return; if (!settingsApplied) return;
@@ -48,7 +103,7 @@
fetch('/api/settings', { fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed }) body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
}).catch(() => {}); }).catch(() => {});
}, 800) as unknown as number; }, 800) as unknown as number;
}); });
@@ -144,7 +199,7 @@
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30); audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
} }
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
function cycleSpeed() { function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed); const idx = speedSteps.indexOf(audioStore.speed);
@@ -170,6 +225,9 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title> <title>libnovel</title>
<!-- Apply theme before first paint to avoid flash -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<script>document.documentElement.setAttribute('data-theme','${data.settings?.theme ?? 'amber'}')</script>`}
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset --> <!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
{#if env.PUBLIC_UMAMI_WEBSITE_ID && env.PUBLIC_UMAMI_SCRIPT_URL} {#if env.PUBLIC_UMAMI_WEBSITE_ID && env.PUBLIC_UMAMI_SCRIPT_URL}
<script <script
@@ -216,18 +274,18 @@
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}> <div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<!-- Navigation progress bar — shown while SSR is running for any page transition --> <!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating} {#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-zinc-800"> <div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
<div class="h-full bg-amber-400 animate-progress-bar"></div> <div class="h-full bg-(--color-brand) animate-progress-bar"></div>
</div> </div>
{/if} {/if}
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50"> <header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6"> <nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300 shrink-0"> <a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
libnovel libnovel
</a> </a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)} {#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-zinc-400 text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs"> <span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
{page.data.book.title} {page.data.book.title}
</span> </span>
{/if} {/if}
@@ -235,83 +293,158 @@
{#if data.user} {#if data.user}
<!-- Desktop nav links (hidden on mobile) --> <!-- Desktop nav links (hidden on mobile) -->
<a <a
href="/books" href="/books"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}" class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
> >
Library {m.nav_library()}
</a> </a>
<a <a
href="/catalogue" href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}" class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
> >
Discover {m.nav_catalogue()}
</a> </a>
<a <a
href="https://feedback.libnovel.cc" href="https://feedback.libnovel.cc"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-zinc-400 hover:text-zinc-100" class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
> >
Feedback {m.nav_feedback()}
</a> </a>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-2">
<!-- Desktop: admin + profile + sign out (hidden on mobile) --> <!-- Theme dots (desktop) -->
{#if data.user?.role === 'admin'} <div class="hidden sm:flex items-center gap-1 mr-1">
<a {#each THEMES as t}
href="/admin/scrape" <button
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}" type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
</div>
<!-- Language dropdown (desktop) -->
<div class="hidden sm:block relative">
<button
type="button"
onclick={() => { langMenuOpen = !langMenuOpen; userMenuOpen = false; }}
class="flex items-center gap-1 px-2 py-1 rounded text-xs font-mono transition-colors {langMenuOpen ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
> >
Scrape <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</a> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
<a </svg>
href="/admin/audio" {getLocale().toUpperCase()}
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}" <svg class="w-3 h-3 shrink-0 transition-transform {langMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{#if langMenuOpen}
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
{#each locales as locale}
<button
type="button"
onclick={async () => {
langMenuOpen = false;
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="w-full text-left px-3 py-1.5 text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{locale.toUpperCase()}
</button>
{/each}
</div>
{/if}
</div>
<!-- User menu dropdown (desktop) -->
<div class="hidden sm:block relative">
<button
type="button"
onclick={() => { userMenuOpen = !userMenuOpen; langMenuOpen = false; }}
class="flex items-center gap-1.5 pl-1.5 pr-2 py-1 rounded transition-colors {userMenuOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'}"
> >
Audio <span class="w-6 h-6 rounded-full bg-(--color-brand)/20 text-(--color-brand) text-xs font-bold flex items-center justify-center shrink-0">
</a> {data.user.username[0].toUpperCase()}
{/if} </span>
<a <svg class="w-3 h-3 text-(--color-muted) transition-transform {userMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
href="/profile" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}" </svg>
> </button>
{data.user.username} {#if userMenuOpen}
</a> <div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[170px]">
<form method="POST" action="/logout" class="hidden sm:block"> <a
<Button type="submit" variant="ghost" size="sm" class="text-zinc-400 hover:text-zinc-100"> href="/profile"
Sign out onclick={() => { userMenuOpen = false; }}
</Button> class="flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
</form> >
{m.nav_profile()}
<span class="text-xs opacity-40 truncate max-w-[80px]">{data.user.username}</span>
</a>
{#if data.user?.role === 'admin'}
<a
href="/admin/scrape"
onclick={() => { userMenuOpen = false; }}
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{m.nav_admin_panel()}
</a>
{/if}
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
{m.nav_sign_out()}
</button>
</form>
</div>
{/if}
</div>
<!-- Mobile: hamburger button --> <!-- Mobile: hamburger button -->
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onclick={() => (menuOpen = !menuOpen)} onclick={() => (menuOpen = !menuOpen)}
aria-label="Toggle menu" aria-label={m.nav_toggle_menu()}
aria-expanded={menuOpen} aria-expanded={menuOpen}
class="sm:hidden -mr-1" class="sm:hidden -mr-1"
> >
{#if menuOpen} {#if menuOpen}
<!-- X icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
{:else} {:else}
<!-- Hamburger icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
{/if} {/if}
</Button> </Button>
</div> </div>
<!-- Click-outside overlay for dropdowns -->
{#if langMenuOpen || userMenuOpen}
<div
class="fixed inset-0 z-40"
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
aria-hidden="true"
></div>
{/if}
{:else} {:else}
<div class="ml-auto"> <div class="ml-auto">
<a <a
href="/login" href="/login"
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors" class="text-sm px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors"
> >
Sign in {m.nav_sign_in()}
</a> </a>
</div> </div>
{/if} {/if}
@@ -319,70 +452,98 @@
<!-- Mobile drawer (full-width, below the bar) --> <!-- Mobile drawer (full-width, below the bar) -->
{#if data.user && menuOpen} {#if data.user && menuOpen}
<div class="sm:hidden border-t border-zinc-700 bg-zinc-900 px-4 py-3 flex flex-col gap-1"> <div class="sm:hidden border-t border-(--color-border) bg-(--color-surface) px-4 py-3 flex flex-col gap-1">
<a <a
href="/books" href="/books"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
> >
Library {m.nav_library()}
</a> </a>
<a <a
href="/catalogue" href="/catalogue"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
> >
Discover {m.nav_catalogue()}
</a> </a>
<a <a
href="https://feedback.libnovel.cc" href="https://feedback.libnovel.cc"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100" class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
> >
Feedback ↗ {m.nav_feedback()}
</a> </a>
<a <a
href="/profile" href="/profile"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
> >
Profile <span class="text-zinc-500 font-normal">({data.user.username})</span> {m.nav_profile()} <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
</a> </a>
{#if data.user?.role === 'admin'} {#if data.user?.role === 'admin'}
<div class="my-1 border-t border-zinc-700/60"></div> <div class="my-1 border-t border-(--color-border)/60"></div>
<p class="px-3 pt-1 pb-0.5 text-xs text-zinc-600 uppercase tracking-widest">Admin</p> <p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">{m.nav_admin()}</p>
<a <a
href="/admin/scrape" href="/admin/scrape"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
> >
Scrape tasks {m.nav_admin_panel()}
</a> </a>
<a {/if}
href="/admin/audio" <!-- Theme switcher -->
onclick={() => (menuOpen = false)} <div class="my-1 border-t border-(--color-border)/60"></div>
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" <div class="px-3 py-2.5 flex items-center justify-between">
> <span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
Audio cache <div class="flex items-center gap-2">
</a> {#each THEMES as t}
<a <button
href="/admin/audio-jobs" type="button"
onclick={() => (menuOpen = false)} onclick={() => { currentTheme = t.id; }}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}" title={t.id}
> class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
Audio jobs style="background: {t.color};"
</a> ></button>
{/if} {/each}
<div class="my-1 border-t border-zinc-700/60"></div> </div>
</div>
<!-- Language switcher -->
<div class="px-3 py-2.5 flex items-center justify-between">
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.locale_switcher_label()}</span>
<div class="flex items-center gap-0.5">
{#each locales as locale}
<button
type="button"
onclick={async () => {
menuOpen = false;
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{locale.toUpperCase()}
</button>
{/each}
</div>
</div>
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout"> <form method="POST" action="/logout">
<Button <Button
type="submit" type="submit"
variant="ghost" variant="ghost"
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-red-400 hover:bg-zinc-800 hover:text-red-300" class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-(--color-danger) hover:bg-(--color-surface-2) hover:text-(--color-danger)"
> >
Sign out {m.nav_sign_out()}
</Button> </Button>
</form> </form>
</div> </div>
@@ -395,19 +556,19 @@
{/key} {/key}
</main> </main>
<footer class="border-t border-zinc-800 mt-auto"> <footer class="border-t border-(--color-border) mt-auto">
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600"> <div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
<!-- Top row: site links --> <!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2"> <nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a> <a href="/books" class="hover:text-(--color-text) transition-colors">{m.footer_library()}</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Discover</a> <a href="/catalogue" class="hover:text-(--color-text) transition-colors">{m.footer_catalogue()}</a>
<a <a
href="https://feedback.libnovel.cc" href="https://feedback.libnovel.cc"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1" class="hover:text-(--color-text) transition-colors flex items-center gap-1"
> >
Feedback {m.footer_feedback()}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
@@ -417,7 +578,7 @@
href="https://novelfire.net" href="https://novelfire.net"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1" class="hover:text-(--color-text) transition-colors flex items-center gap-1"
> >
novelfire.net novelfire.net
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -425,37 +586,56 @@
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
</a> </a>
</nav> </nav>
<!-- Bottom row: legal links + copyright --> <!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-zinc-700"> <div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
<a href="/disclaimer" class="hover:text-zinc-500 transition-colors">Disclaimer</a> <a href="/disclaimer" class="hover:text-(--color-text) transition-colors">{m.footer_disclaimer()}</a>
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a> <a href="/privacy" class="hover:text-(--color-text) transition-colors">{m.footer_privacy()}</a>
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a> <a href="/dmca" class="hover:text-(--color-text) transition-colors">{m.footer_dmca()}</a>
<span>&copy; {new Date().getFullYear()} libnovel</span> <span>{m.footer_copyright({ year: String(new Date().getFullYear()) })}</span>
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'} </div>
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span> <!-- Build version / commit SHA / build time -->
{#snippet buildTime()}
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
<span class="text-(--color-muted)" title="Build time">
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
</span>
{/if}
{/snippet}
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-(--color-surface-2) border border-(--color-border)">
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
<span class="text-(--color-text)" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
{#if env.PUBLIC_BUILD_COMMIT && env.PUBLIC_BUILD_COMMIT !== 'unknown'}
<span class="text-(--color-muted) select-all" title="Commit SHA"
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
>
{/if} {/if}
</div> {@render buildTime()}
{:else}
<span class="text-(--color-muted)">dev</span>
{/if}
</div>
</div> </div>
</footer> </footer>
</div> </div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── --> <!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active} {#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl"> <div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
<!-- Chapter list drawer (slides up above the mini-bar) --> <!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0} {#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-zinc-700 bg-zinc-900 max-h-[32rem] overflow-y-auto"> <div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
<div class="max-w-6xl mx-auto px-4"> <div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900"> <div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span> <span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onclick={() => (chapterDrawerOpen = false)} onclick={() => (chapterDrawerOpen = false)}
aria-label="Close chapter list" aria-label={m.player_close_chapter_list()}
class="h-6 w-6 text-zinc-600 hover:text-zinc-300" class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
> >
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
@@ -466,16 +646,16 @@
<a <a
href="/books/{audioStore.slug}/chapters/{ch.number}" href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)} onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
? 'text-amber-400 font-semibold' ? 'text-(--color-brand) font-semibold'
: 'text-zinc-400'}" : 'text-(--color-muted)'}"
> >
<span class="tabular-nums text-zinc-600 w-8 shrink-0 text-right"> <span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
{ch.number} {ch.number}
</span> </span>
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span> <span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
{#if ch.number === audioStore.chapter} {#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
{/if} {/if}
@@ -487,9 +667,9 @@
<!-- Generation progress bar (sits at very top of the bar) --> <!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'} {#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-zinc-800"> <div class="h-0.5 bg-(--color-surface-2)">
<div <div
class="h-full bg-amber-400 transition-none" class="h-full bg-(--color-brand) transition-none"
style="width: {audioStore.progress}%" style="width: {audioStore.progress}%"
></div> ></div>
</div> </div>
@@ -498,12 +678,13 @@
<div class="px-0"> <div class="px-0">
<input <input
type="range" type="range"
aria-label={m.player_seek_label()}
min="0" min="0"
max={audioStore.duration || 0} max={audioStore.duration || 0}
value={audioStore.currentTime} value={audioStore.currentTime}
oninput={seek} oninput={seek}
class="w-full h-1 accent-amber-400 cursor-pointer block" class="w-full h-1 accent-[--color-brand] cursor-pointer block"
style="margin: 0; border-radius: 0;" style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
/> />
</div> </div>
{/if} {/if}
@@ -512,27 +693,27 @@
<!-- Track info (click to open chapter list drawer) --> <!-- Track info (click to open chapter list drawer) -->
<button <button
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors" class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }} onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined} aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined} title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
> >
{#if audioStore.chapterTitle} {#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p> <p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
{/if} {/if}
{#if audioStore.bookTitle} {#if audioStore.bookTitle}
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p> <p class="text-xs text-(--color-muted) truncate leading-tight">{audioStore.bookTitle}</p>
{/if} {/if}
{#if audioStore.status === 'generating'} {#if audioStore.status === 'generating'}
<p class="text-xs text-amber-400 leading-tight"> <p class="text-xs text-(--color-brand) leading-tight">
Generating… {Math.round(audioStore.progress)}% {m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
</p> </p>
{:else if audioStore.status === 'ready'} {:else if audioStore.status === 'ready'}
<p class="text-xs text-zinc-500 tabular-nums leading-tight"> <p class="text-xs text-(--color-muted) tabular-nums leading-tight">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</p> </p>
{:else if audioStore.status === 'loading'} {:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p> <p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
{/if} {/if}
</button> </button>
@@ -542,8 +723,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
onclick={skipBack} onclick={skipBack}
title="Back 15s" title={m.player_back_15()}
aria-label="Rewind 15 seconds" aria-label={m.player_rewind_15()}
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/> <path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
@@ -551,11 +732,11 @@
</svg> </svg>
</Button> </Button>
<!-- Play / Pause — custom circular amber style, kept as raw button --> <!-- Play / Pause — custom circular brand style, kept as raw button -->
<button <button
onclick={togglePlay} onclick={togglePlay}
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0" class="w-10 h-10 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'} aria-label={audioStore.isPlaying ? m.player_pause() : m.player_play()}
> >
{#if audioStore.isPlaying} {#if audioStore.isPlaying}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
@@ -573,8 +754,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
onclick={skipForward} onclick={skipForward}
title="Forward 30s" title={m.player_forward_30()}
aria-label="Skip 30 seconds" aria-label={m.player_skip_30()}
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/> <path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
@@ -585,9 +766,9 @@
<!-- Speed control — fixed-width pill, kept as raw button --> <!-- Speed control — fixed-width pill, kept as raw button -->
<button <button
onclick={cycleSpeed} onclick={cycleSpeed}
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center" class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
title="Change playback speed" title={m.player_change_speed()}
aria-label="Playback speed {audioStore.speed}x" aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
> >
{audioStore.speed}× {audioStore.speed}×
</button> </button>
@@ -598,17 +779,17 @@
class={cn( class={cn(
'relative p-1.5 rounded flex-shrink-0 transition-colors', 'relative p-1.5 rounded flex-shrink-0 transition-colors',
audioStore.autoNext audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25'
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
)} )}
title={audioStore.autoNext title={audioStore.autoNext
? audioStore.nextStatus === 'prefetched' ? audioStore.nextStatus === 'prefetched'
? `Auto-next on Ch.${audioStore.nextChapter} ready` ? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
: audioStore.nextStatus === 'prefetching' : audioStore.nextStatus === 'prefetching'
? `Auto-next on preparing Ch.${audioStore.nextChapter}` ? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
: 'Auto-next on' : m.player_auto_next_on()
: 'Auto-next off'} : m.player_auto_next_off()}
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}" aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
aria-pressed={audioStore.autoNext} aria-pressed={audioStore.autoNext}
> >
<!-- "skip to end" / auto-advance icon --> <!-- "skip to end" / auto-advance icon -->
@@ -617,14 +798,14 @@
</svg> </svg>
<!-- Prefetch status dot --> <!-- Prefetch status dot -->
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'} {#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span> <span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'} {:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span> <span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if} {/if}
</button> </button>
{:else if audioStore.status === 'generating'} {:else if audioStore.status === 'generating'}
<!-- Spinner during generation --> <!-- Spinner during generation -->
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-(--color-brand) animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
@@ -635,8 +816,8 @@
<a <a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}" href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity" class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
title="Go to chapter" title={m.player_go_to_chapter()}
aria-label="Go to chapter" aria-label={m.player_go_to_chapter()}
> >
{#if audioStore.cover} {#if audioStore.cover}
<img <img
@@ -646,8 +827,8 @@
/> />
{:else} {:else}
<!-- Fallback book icon --> <!-- Fallback book icon -->
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700"> <div class="w-8 h-11 flex items-center justify-center bg-(--color-surface-2) rounded border border-(--color-border)">
<svg class="w-4 h-4 text-zinc-500" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/> <path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg> </svg>
</div> </div>
@@ -660,9 +841,9 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
onclick={dismiss} onclick={dismiss}
title="Close player" title={m.player_close()}
aria-label="Close player" aria-label={m.player_close()}
class="text-zinc-600 hover:text-zinc-400 flex-shrink-0" class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -16,22 +17,22 @@
</script> </script>
<svelte:head> <svelte:head>
<title>libnovel</title> <title>{m.home_title()}</title>
</svelte:head> </svelte:head>
<!-- Stats bar --> <!-- Stats bar -->
<div class="flex gap-6 mb-8 text-center"> <div class="flex gap-6 mb-8 text-center">
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6"> <div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p> <p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
<p class="text-xs text-zinc-400 mt-0.5">Books</p> <p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
</div> </div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6"> <div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p> <p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p> <p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
</div> </div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6"> <div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p> <p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
<p class="text-xs text-zinc-400 mt-0.5">In progress</p> <p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
</div> </div>
</div> </div>
@@ -39,16 +40,16 @@
{#if data.continueReading.length > 0} {#if data.continueReading.length > 0}
<section class="mb-10"> <section class="mb-10">
<div class="flex items-baseline justify-between mb-3"> <div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2> <h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a> <a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.continueReading as { book, chapter }} {#each data.continueReading as { book, chapter }}
<a <a
href="/books/{book.slug}/chapters/{chapter}" href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500" class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
> >
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative"> <div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover} {#if book.cover}
<img <img
src={book.cover} src={book.cover}
@@ -57,7 +58,7 @@
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600"> <div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -65,14 +66,14 @@
</div> </div>
{/if} {/if}
<!-- Chapter badge overlay --> <!-- Chapter badge overlay -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded"> <span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
ch.{chapter} {m.home_chapter_badge({ n: String(chapter) })}
</span> </span>
</div> </div>
<div class="p-2"> <div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3> <h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author} {#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p> <p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if} {/if}
</div> </div>
</a> </a>
@@ -85,17 +86,17 @@
{#if data.recentlyUpdated.length > 0} {#if data.recentlyUpdated.length > 0}
<section class="mb-10"> <section class="mb-10">
<div class="flex items-baseline justify-between mb-3"> <div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2> <h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a> <a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.recentlyUpdated as book} {#each data.recentlyUpdated as book}
{@const genres = parseGenres(book.genres)} {@const genres = parseGenres(book.genres)}
<a <a
href="/books/{book.slug}" href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500" class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
> >
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden"> <div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover} {#if book.cover}
<img <img
src={book.cover} src={book.cover}
@@ -104,7 +105,7 @@
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600"> <div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -113,17 +114,17 @@
{/if} {/if}
</div> </div>
<div class="p-2 flex flex-col gap-1"> <div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3> <h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author} {#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p> <p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if} {/if}
{#if book.status} {#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span> <span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) self-start">{book.status}</span>
{/if} {/if}
{#if genres.length > 0} {#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1"> <div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 2) as genre} {#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span> <span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -136,14 +137,14 @@
<!-- Empty state --> <!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0} {#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<div class="text-center py-20 text-zinc-500"> <div class="text-center py-20 text-(--color-muted)">
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p> <p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p> <p class="text-sm mb-6">{m.home_empty_body()}</p>
<a <a
href="/catalogue" href="/catalogue"
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors" class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
> >
Discover Novels {m.home_discover_novels()}
</a> </a>
</div> </div>
{/if} {/if}
@@ -152,16 +153,16 @@
{#if data.subscriptionFeed.length > 0} {#if data.subscriptionFeed.length > 0}
<section class="mb-10"> <section class="mb-10">
<div class="flex items-baseline justify-between mb-3"> <div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2> <h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.subscriptionFeed as { book, readerUsername }} {#each data.subscriptionFeed as { book, readerUsername }}
{@const genres = parseGenres(book.genres)} {@const genres = parseGenres(book.genres)}
<a <a
href="/books/{book.slug}" href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500" class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
> >
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden"> <div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover} {#if book.cover}
<img <img
src={book.cover} src={book.cover}
@@ -170,7 +171,7 @@
loading="lazy" loading="lazy"
/> />
{:else} {:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600"> <div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
@@ -179,18 +180,18 @@
{/if} {/if}
</div> </div>
<div class="p-2 flex flex-col gap-1"> <div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3> <h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author} {#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p> <p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if} {/if}
<!-- Reader attribution --> <!-- Reader attribution -->
<p class="text-xs text-zinc-600 truncate mt-0.5"> <p class="text-xs text-(--color-muted) truncate mt-0.5">
via <span class="text-amber-500/70">{readerUsername}</span> {m.home_via_reader({ username: readerUsername })}
</p> </p>
{#if genres.length > 0} {#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1"> <div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 1) as genre} {#each genres.slice(0, 1) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span> <span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
const adminTabs = [ const internalLinks = [
{ href: '/admin/scrape', label: 'Scrape' }, { href: '/admin/scrape', label: 'Scrape' },
{ href: '/admin/audio', label: 'Audio' } { href: '/admin/audio', label: 'Audio' },
{ href: '/admin/translation', label: 'Translation' },
{ href: '/admin/changelog', label: 'Changelog' }
]; ];
const toolTabs = [ const externalLinks = [
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' }, { href: 'https://feedback.libnovel.cc', label: 'Feedback' },
{ href: 'https://errors.libnovel.cc', label: 'Errors' }, { href: 'https://errors.libnovel.cc', label: 'Errors' },
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' }, { href: 'https://analytics.libnovel.cc', label: 'Analytics' },
@@ -21,36 +24,51 @@
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<!-- Admin nav: internal pages + external tools --> <div class="flex min-h-[calc(100vh-4rem)] gap-0">
<div class="mb-6 flex flex-wrap items-center gap-3"> <!-- Sidebar -->
<!-- Internal admin pages --> <aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700"> <!-- Internal pages -->
{#each adminTabs as tab} <div>
<a <p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
href={tab.href} <nav class="flex flex-col gap-0.5">
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors {#each internalLinks as link}
{page.url.pathname.startsWith(tab.href) <a
? 'bg-zinc-700 text-zinc-100' href={link.href}
: 'text-zinc-400 hover:text-zinc-200'}" class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
> {page.url.pathname.startsWith(link.href)
{tab.label} ? 'bg-(--color-surface-2) text-(--color-text)'
</a> : 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
{/each} >
</div> {link.label}
</a>
{/each}
</nav>
</div>
<!-- External tools (open in new tab) --> <!-- External tools -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700"> <div>
{#each toolTabs as tool} <p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
<a <nav class="flex flex-col gap-0.5">
href={tool.href} {#each externalLinks as link}
target="_blank" <a
rel="noopener noreferrer" href={link.href}
class="px-4 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors" target="_blank"
> rel="noopener noreferrer"
{tool.label} class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
</a> >
{/each} {link.label}
</div> <svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/each}
</nav>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 min-w-0 px-8 py-6">
{@render children?.()}
</main>
</div> </div>
{@render children?.()}

View File

@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase'; import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -33,10 +34,10 @@
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
function jobStatusColor(status: string) { function jobStatusColor(status: string) {
if (status === 'done') return 'text-green-400'; if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse'; if (status === 'generating') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse'; if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400'; if (status === 'failed') return 'text-(--color-danger)';
return 'text-zinc-300'; return 'text-(--color-text)';
} }
function fmtDate(s: string) { function fmtDate(s: string) {
@@ -94,36 +95,36 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Audio — libnovel admin</title> <title>{m.admin_audio_page_title()}</title>
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1> <h1 class="text-2xl font-bold text-(--color-text)">{m.admin_audio_heading()}</h1>
<p class="text-zinc-400 text-sm mt-1"> <p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot; {stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span> <span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0} {#if stats.failed > 0}
&middot; <span class="text-red-400">{stats.failed} failed</span> &middot; <span class="text-(--color-danger)">{stats.failed} failed</span>
{/if} {/if}
{#if stats.inFlight > 0} {#if stats.inFlight > 0}
&middot; <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span> &middot; <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
{/if} {/if}
&middot; {entries.length} cached file{entries.length !== 1 ? 's' : ''} &middot; {entries.length} cached file{entries.length !== 1 ? 's' : ''}
</p> </p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700"> <div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
<button <button
onclick={() => (activeTab = 'jobs')} onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}" {activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
> >
Jobs Jobs
{#if stats.inFlight > 0} {#if stats.inFlight > 0}
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold"> <span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold">
{stats.inFlight} {stats.inFlight}
</span> </span>
{/if} {/if}
@@ -131,7 +132,7 @@
<button <button
onclick={() => (activeTab = 'cache')} onclick={() => (activeTab = 'cache')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}" {activeTab === 'cache' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
> >
Cache Cache
</button> </button>
@@ -142,19 +143,19 @@
<input <input
type="search" type="search"
bind:value={jobsQ} bind:value={jobsQ}
placeholder="Filter by slug, voice or status…" placeholder={m.admin_audio_filter_jobs()}
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/> />
{#if filteredJobs.length === 0} {#if filteredJobs.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center"> <p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'} {jobsQ.trim() ? m.admin_audio_no_matching_jobs() : m.admin_audio_no_jobs()}
</p> </p>
{:else} {:else}
<!-- Desktop table --> <!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700"> <div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide"> <thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr> <tr>
<th class="px-4 py-3 text-left">Book</th> <th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th> <th class="px-4 py-3 text-right">Ch.</th>
@@ -164,23 +165,23 @@
<th class="px-4 py-3 text-left">Duration</th> <th class="px-4 py-3 text-left">Duration</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-700/50"> <tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job} {#each filteredJobs as job}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors"> <tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium"> <td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a> <a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
</td> </td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td> <td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td> <td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span> <span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td> </td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td> <td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td> <td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
</tr> </tr>
{#if job.error_message} {#if job.error_message}
<tr class="bg-red-950/20"> <tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono">{job.error_message}</td> <td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr> </tr>
{/if} {/if}
{/each} {/each}
@@ -191,21 +192,21 @@
<!-- Mobile cards --> <!-- Mobile cards -->
<div class="sm:hidden space-y-3"> <div class="sm:hidden space-y-3">
{#each filteredJobs as job} {#each filteredJobs as job}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2"> <div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors truncate"> <a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
{job.slug} {job.slug}
</a> </a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span> <span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div> </div>
<div class="grid grid-cols-2 gap-1 text-xs"> <div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span> <span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span> <span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span> <span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span> <span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div> </div>
{#if job.error_message} {#if job.error_message}
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p> <p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -218,19 +219,19 @@
<input <input
type="search" type="search"
bind:value={cacheQ} bind:value={cacheQ}
placeholder="Filter by slug, chapter or voice…" placeholder={m.admin_audio_filter_cache()}
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/> />
{#if filteredCache.length === 0} {#if filteredCache.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center"> <p class="text-(--color-muted) text-sm py-8 text-center">
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'} {cacheQ.trim() ? m.admin_audio_no_cache_results() : m.admin_audio_cache_empty()}
</p> </p>
{:else} {:else}
<!-- Desktop table --> <!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700"> <div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide"> <thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr> <tr>
<th class="px-4 py-3 text-left">Book</th> <th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th> <th class="px-4 py-3 text-left">Chapter</th>
@@ -239,19 +240,19 @@
<th class="px-4 py-3 text-left">Updated</th> <th class="px-4 py-3 text-left">Updated</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-700/50"> <tbody class="divide-y divide-(--color-border)/50">
{#each filteredCache as entry} {#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)} {@const parts = parseCacheKey(entry.cache_key)}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors"> <tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium"> <td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{parts.slug}" class="hover:text-amber-400 transition-colors">{parts.slug}</a> <a href="/books/{parts.slug}" class="hover:text-(--color-brand) transition-colors">{parts.slug}</a>
</td> </td>
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td> <td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td> <td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}> <td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename} {entry.filename}
</td> </td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td> <td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(entry.updated)}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -262,17 +263,17 @@
<div class="sm:hidden space-y-3"> <div class="sm:hidden space-y-3">
{#each filteredCache as entry} {#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)} {@const parts = parseCacheKey(entry.cache_key)}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2"> <div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate"> <a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
{parts.slug} {parts.slug}
</a> </a>
<div class="grid grid-cols-2 gap-1 text-xs"> <div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span> <span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span> <span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span> <span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div> </div>
{#if entry.filename} {#if entry.filename}
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p> <p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if} {/if}
</div> </div>
{/each} {/each}

View File

@@ -0,0 +1,26 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import type { PageServerLoad } from './$types';
export interface Release {
id: number;
tag_name: string;
name: string;
body: string;
published_at: string;
prerelease: boolean;
draft: boolean;
}
export const load: PageServerLoad = async () => {
try {
// releases.json is baked into the image at build time by CI.
// SvelteKit Node adapter copies static/ → build/client/, so the file
// lives at <cwd>/build/client/releases.json in production.
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
const releases: Release[] = JSON.parse(raw);
return { releases: releases.filter((r) => !r.draft) };
} catch (e) {
return { releases: [], error: String(e) };
}
};

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
function fmtDate(s: string) {
return new Date(s).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric'
});
}
</script>
<svelte:head>
<title>{m.admin_changelog_page_title()}</title>
</svelte:head>
<div class="space-y-6 max-w-2xl">
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_changelog_heading()}</h1>
<a
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
>
{m.admin_changelog_gitea()}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
{#if data.error}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { ScrapingTask } from '$lib/server/pocketbase'; import type { ScrapingTask } from '$lib/server/pocketbase';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -193,10 +194,10 @@
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) { function statusColor(status: string) {
if (status === 'done') return 'text-green-400'; if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-amber-400 animate-pulse'; if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'failed') return 'text-red-400'; if (status === 'failed') return 'text-(--color-danger)';
if (status === 'cancelled') return 'text-zinc-400'; if (status === 'cancelled') return 'text-(--color-muted)';
return 'text-zinc-300'; return 'text-(--color-text)';
} }
function fmtDate(s: string) { function fmtDate(s: string) {
@@ -228,151 +229,130 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Scrape tasks — libnovel admin</title> <title>{m.admin_scrape_page_title()}</title>
</svelte:head> </svelte:head>
<div class="space-y-8"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3"> <div class="flex items-center gap-3 flex-wrap">
<div> <h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1> <span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
<p class="text-zinc-400 text-sm mt-1"> {running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
Job status: </span>
{#if running}
<span class="text-amber-400 font-medium animate-pulse">Running</span>
{:else}
<span class="text-green-400 font-medium">Idle</span>
{/if}
</p>
</div>
</div> </div>
<!-- Scrape controls --> <!-- Compact controls -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Full catalogue --> <!-- Full catalogue -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3"> <div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
<div> <span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
</div>
<button <button
onclick={triggerCatalogueScrape} onclick={triggerCatalogueScrape}
disabled={running || cataloguing} disabled={running || cataloguing}
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50" class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
> >
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'} {cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
</button> </button>
{#if catalogueError} {#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
<p class="text-sm text-red-400">{catalogueError}</p>
{/if}
</div> </div>
<!-- Single book --> <!-- Single book -->
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3"> <div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2> <span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
<div class="flex gap-2"> <input
<input type="url"
type="url" bind:value={scrapeUrl}
bind:value={scrapeUrl} placeholder="https://novelfire.net/book/…"
placeholder="https://novelfire.net/book/…" class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" />
/> <button
<button onclick={() => triggerBookScrape(scrapeUrl)}
onclick={() => triggerBookScrape(scrapeUrl)} disabled={!scrapeUrl.trim() || running || scraping}
disabled={!scrapeUrl.trim() || running || scraping} class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50" >
> {scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
{scraping ? 'Queuing…' : 'Scrape'} </button>
</button> {#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
</div>
{#if scrapeError}
<p class="text-sm text-red-400">{scrapeError}</p>
{/if}
</div> </div>
<!-- Range scrape --> <!-- Range scrape -->
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3"> <div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2> <span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
<input <input
type="url" type="url"
bind:value={rangeUrl} bind:value={rangeUrl}
placeholder="https://novelfire.net/book/…" placeholder="https://novelfire.net/book/…"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/> />
<div class="flex gap-2"> <input
<input type="number"
type="number" bind:value={rangeFrom}
bind:value={rangeFrom} min="1"
min="1" placeholder="From"
placeholder="From ch." class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" />
/> <input
<input type="number"
type="number" bind:value={rangeTo}
bind:value={rangeTo} min="1"
min="1" placeholder="To"
placeholder="To ch. (opt)" class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" />
/> <button
<button onclick={triggerRangeScrape}
onclick={triggerRangeScrape} disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging} class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
>
{ranging ? 'Queuing…' : 'Go'}
</button>
</div>
{#if rangeError}
<p class="text-sm text-red-400">{rangeError}</p>
{/if}
</div>
</div>
<!-- Quick-scrape genre links -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
<div class="flex flex-wrap gap-2">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
>
{qs.label}
</button>
{/each}
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
> >
Browse novelfire.net ↗ {ranging ? m.admin_scrape_queuing() : 'Go'}
</a> </button>
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
</div>
<!-- Quick genre chips -->
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
<div class="flex flex-wrap gap-1.5">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand-dim) transition-colors"
>
{qs.label}
</button>
{/each}
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-2.5 py-1 rounded text-xs font-medium text-(--color-muted) border border-(--color-border)/50 hover:text-(--color-brand-dim) hover:border-(--color-brand)/40 transition-colors"
>
novelfire.net ↗
</a>
</div>
</div> </div>
</div> </div>
<!-- Tasks table --> <!-- Tasks table -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2> <h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
<input <input
type="search" type="search"
bind:value={q} bind:value={q}
placeholder="Filter by kind, status or URL…" placeholder={m.admin_scrape_filter_placeholder()}
class="w-full max-w-xs bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400" class="w-full max-w-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/> />
</div> </div>
{#if filtered.length === 0} {#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center"> <p class="text-(--color-muted) text-sm py-8 text-center">
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'} {q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
</p> </p>
{:else} {:else}
<!-- Desktop table --> <!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700"> <div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide"> <thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr> <tr>
<th class="px-4 py-3 text-left">Kind / URL</th> <th class="px-4 py-3 text-left">Kind / URL</th>
<th class="px-4 py-3 text-left">Status</th> <th class="px-4 py-3 text-left">Status</th>
@@ -385,14 +365,14 @@
<th class="px-4 py-3 text-left">Actions</th> <th class="px-4 py-3 text-left">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-zinc-700/50"> <tbody class="divide-y divide-(--color-border)/50">
{#each filtered as task} {#each filtered as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors"> <tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300"> <td class="px-4 py-3 font-mono text-xs text-(--color-text)">
{task.kind} {task.kind}
{#if task.target_url} {#if task.target_url}
<br /> <br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}> <span class="text-(--color-muted) truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')} {task.target_url.replace('https://novelfire.net/book/', '')}
</span> </span>
{/if} {/if}
@@ -400,21 +380,21 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span> <span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td> </td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td> <td class="px-4 py-3 text-right text-(--color-text)">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td> <td class="px-4 py-3 text-right text-(--color-text)">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td> <td class="px-4 py-3 text-right text-(--color-muted)">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td> <td class="px-4 py-3 text-right {task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td> <td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(task.started, task.finished)}</td> <td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#if task.status === 'pending'} {#if task.status === 'pending'}
<button <button
onclick={() => cancelTask(task.id)} onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)} disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50" class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
> >
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'} {cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
</button> </button>
{/if} {/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0} {#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
@@ -434,14 +414,14 @@
</button> </button>
{/if} {/if}
{#if cancelErrors[task.id]} {#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1 w-full">{cancelErrors[task.id]}</p> <p class="text-xs text-(--color-danger) mt-1 w-full">{cancelErrors[task.id]}</p>
{/if} {/if}
</div> </div>
</td> </td>
</tr> </tr>
{#if task.error_message} {#if task.error_message}
<tr class="bg-red-950/20"> <tr class="bg-(--color-danger)/10">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td> <td colspan="9" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{task.error_message}</td>
</tr> </tr>
{/if} {/if}
{/each} {/each}
@@ -452,12 +432,12 @@
<!-- Mobile cards --> <!-- Mobile cards -->
<div class="sm:hidden space-y-3"> <div class="sm:hidden space-y-3">
{#each filtered as task} {#each filtered as task}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2"> <div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<span class="font-mono text-xs text-zinc-300">{task.kind}</span> <span class="font-mono text-xs text-(--color-text)">{task.kind}</span>
{#if task.target_url} {#if task.target_url}
<p class="text-xs text-zinc-500 truncate mt-0.5" title={task.target_url}> <p class="text-xs text-(--color-muted) truncate mt-0.5" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')} {task.target_url.replace('https://novelfire.net/book/', '')}
</p> </p>
{/if} {/if}
@@ -465,24 +445,24 @@
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span> <span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
</div> </div>
<div class="grid grid-cols-2 gap-1 text-xs"> <div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span> <span class="text-(--color-muted)">Books</span><span class="text-(--color-text) text-right">{task.books_found ?? 0}</span>
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span> <span class="text-(--color-muted)">Chapters</span><span class="text-(--color-text) text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span> <span class="text-(--color-muted)">Skipped</span><span class="text-(--color-muted) text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span> <span class="text-(--color-muted)">Errors</span><span class="{task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'} text-right">{task.errors ?? 0}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span> <span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(task.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span> <span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(task.started, task.finished)}</span>
</div> </div>
{#if task.error_message} {#if task.error_message}
<p class="text-xs text-red-400 font-mono break-all">{task.error_message}</p> <p class="text-xs text-(--color-danger) font-mono break-all">{task.error_message}</p>
{/if} {/if}
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#if task.status === 'pending'} {#if task.status === 'pending'}
<button <button
onclick={() => cancelTask(task.id)} onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)} disabled={cancellingIds.has(task.id)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50" class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
> >
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'} {cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
</button> </button>
{/if} {/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0} {#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
@@ -502,7 +482,7 @@
</button> </button>
{/if} {/if}
{#if cancelErrors[task.id]} {#if cancelErrors[task.id]}
<p class="text-xs text-red-400 w-full">{cancelErrors[task.id]}</p> <p class="text-xs text-(--color-danger) w-full">{cancelErrors[task.id]}</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,63 @@
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { listBooks, listTranslationJobs, type TranslationJob } from '$lib/server/pocketbase';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const [books, jobs] = await Promise.all([
listBooks().catch((e): Awaited<ReturnType<typeof listBooks>> => {
log.warn('admin/translation', 'failed to load books', { err: String(e) });
return [];
}),
listTranslationJobs().catch((e): TranslationJob[] => {
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
return [];
})
]);
return { books, jobs };
};
export const actions: Actions = {
bulk: async ({ request, locals }) => {
if (locals.user?.role !== 'admin') {
return { success: false, error: 'Unauthorized' };
}
const form = await request.formData();
const slug = form.get('slug')?.toString().trim() ?? '';
const lang = form.get('lang')?.toString().trim() ?? '';
const from = parseInt(form.get('from')?.toString() ?? '1', 10);
const to = parseInt(form.get('to')?.toString() ?? '1', 10);
if (!slug || !lang) {
return { success: false, error: 'slug and lang are required' };
}
if (isNaN(from) || isNaN(to) || from < 1 || to < from) {
return { success: false, error: 'Invalid chapter range' };
}
try {
const res = await backendFetch('/api/admin/translation/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, lang, from, to })
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('admin/translation', 'bulk enqueue failed', { status: res.status, body });
return { success: false, error: `Backend error ${res.status}: ${body}` };
}
const data = await res.json();
return { success: true, enqueued: data.enqueued as number };
} catch (e) {
log.error('admin/translation', 'bulk enqueue fetch error', { err: String(e) });
return { success: false, error: String(e) };
}
}
};

View File

@@ -0,0 +1,340 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import type { TranslationJob } from '$lib/server/pocketbase';
let { data, form }: { data: PageData; form: ActionData } = $props();
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
// Keep in sync on server reloads
$effect(() => {
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
}, 3000);
return () => clearInterval(id);
});
// ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'enqueue' | 'jobs';
let activeTab = $state<Tab>('enqueue');
// ── Bulk enqueue form ─────────────────────────────────────────────────────────
let slugInput = $state('');
let langInput = $state('ru');
let fromInput = $state(1);
let toInput = $state(1);
let submitting = $state(false);
const langs = [
{ value: 'ru', label: 'Russian (ru)' },
{ value: 'id', label: 'Indonesian (id)' },
{ value: 'pt', label: 'Portuguese (pt)' },
{ value: 'fr', label: 'French (fr)' }
];
// ── Jobs helpers ──────────────────────────────────────────────────────────────
function jobStatusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-(--color-brand) animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-(--color-danger)';
return 'text-(--color-text)';
}
function fmtDate(s: string) {
if (!s || s.startsWith('0001')) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function duration(started: string, finished: string) {
if (!started || !finished || started.startsWith('0001') || finished.startsWith('0001'))
return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
let jobsQ = $state('');
let filteredJobs = $derived(
jobsQ.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.lang.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
)
: jobs
);
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'running').length
});
</script>
<svelte:head>
<title>Translation — Admin</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Machine Translation</h1>
<p class="text-(--color-muted) text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0}
&middot; <span class="text-(--color-danger)">{stats.failed} failed</span>
{/if}
{#if stats.inFlight > 0}
&middot; <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
{/if}
</p>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
<button
onclick={() => (activeTab = 'enqueue')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'enqueue' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Enqueue
</button>
<button
onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Jobs
{#if stats.inFlight > 0}
<span
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold"
>
{stats.inFlight}
</span>
{/if}
</button>
</div>
<!-- ── Enqueue tab ─────────────────────────────────────────────────────────── -->
{#if activeTab === 'enqueue'}
<div class="max-w-lg space-y-5">
<!-- Result banner -->
{#if form?.success}
<div class="rounded-lg border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-400">
Enqueued {form.enqueued} translation job{form.enqueued !== 1 ? 's' : ''} successfully.
</div>
{:else if form?.error}
<div class="rounded-lg border border-(--color-danger)/40 bg-(--color-danger)/10 px-4 py-3 text-sm text-(--color-danger)">
{form.error}
</div>
{/if}
<form
method="POST"
action="?/bulk"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
activeTab = 'jobs';
};
}}
class="space-y-4"
>
<!-- Book slug -->
<div class="space-y-1">
<label for="slug" class="block text-sm font-medium text-(--color-text)">Book slug</label>
<input
id="slug"
name="slug"
type="text"
required
list="book-slugs"
bind:value={slugInput}
placeholder="e.g. the-beginning-after-the-end"
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
<datalist id="book-slugs">
{#each data.books as book}
<option value={book.slug}>{book.title}</option>
{/each}
</datalist>
</div>
<!-- Language -->
<div class="space-y-1">
<label for="lang" class="block text-sm font-medium text-(--color-text)">Target language</label>
<select
id="lang"
name="lang"
bind:value={langInput}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
>
{#each langs as l}
<option value={l.value}>{l.label}</option>
{/each}
</select>
</div>
<!-- Chapter range -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label for="from" class="block text-sm font-medium text-(--color-text)">From chapter</label>
<input
id="from"
name="from"
type="number"
min="1"
required
bind:value={fromInput}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
<div class="space-y-1">
<label for="to" class="block text-sm font-medium text-(--color-text)">To chapter</label>
<input
id="to"
name="to"
type="number"
min="1"
required
bind:value={toInput}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
</div>
<p class="text-xs text-(--color-muted)">
Enqueues {Math.max(0, toInput - fromInput + 1)} task{toInput - fromInput + 1 !== 1 ? 's' : ''} — one per chapter. Max 1000 at a time.
</p>
<button
type="submit"
disabled={submitting}
class="w-full sm:w-auto px-6 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Enqueueing…' : 'Enqueue translations'}
</button>
</form>
</div>
{/if}
<!-- ── Jobs tab ───────────────────────────────────────────────────────────── -->
{#if activeTab === 'jobs'}
<input
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, lang, or status…"
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
{#if filteredJobs.length === 0}
<p class="text-(--color-muted) text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No translation jobs yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
<table class="w-full text-sm">
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Lang</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-(--color-border)/50">
{#each filteredJobs as job}
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
<td class="px-4 py-3 text-(--color-text) font-medium">
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
>{job.slug}</a
>
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
>{duration(job.started, job.finished)}</td
>
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
>{job.error_message}</td
>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a
href="/books/{job.slug}"
class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate"
>
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}"
>{job.status}</span
>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span
class="text-(--color-muted) text-right">{job.chapter}</span
>
<span class="text-(--color-muted)">Lang</span><span
class="text-(--color-muted) font-mono text-right uppercase">{job.lang}</span
>
<span class="text-(--color-muted)">Started</span><span
class="text-(--color-muted) text-right">{fmtDate(job.started)}</span
>
<span class="text-(--color-muted)">Duration</span><span
class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span
>
</div>
{#if job.error_message}
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -2,6 +2,34 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger'; import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper'; import { backendFetch } from '$lib/server/scraper';
import * as cache from '$lib/server/cache';
const FREE_DAILY_AUDIO_LIMIT = 3;
/**
* Return the number of audio chapters a user/session has generated today,
* and increment the counter. Uses a Valkey key that expires at midnight UTC.
*
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
*/
async function incrementDailyAudioCount(identifier: string): Promise<number> {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const key = `audio:daily:${identifier}:${today}`;
// Seconds until end of day UTC
const now = new Date();
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
// Use raw get/set with increment so we can read + increment atomically
try {
const raw = await cache.get<number>(key);
const current = (raw ?? 0) + 1;
await cache.set(key, current, ttl);
return current;
} catch {
// On cache failure, fail open (don't block audio for cache errors)
return 0;
}
}
/** /**
* POST /api/audio/[slug]/[n] * POST /api/audio/[slug]/[n]
@@ -15,14 +43,39 @@ import { backendFetch } from '$lib/server/scraper';
* GET /api/presign/audio to obtain a direct MinIO presigned URL. * GET /api/presign/audio to obtain a direct MinIO presigned URL.
* 202 { task_id: string, status: "pending"|"generating" } — generation * 202 { task_id: string, status: "pending"|"generating" } — generation
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done. * enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
* 402 { error: "pro_required", limit: 3 } — free daily limit reached.
*/ */
export const POST: RequestHandler = async ({ params, request }) => { export const POST: RequestHandler = async ({ params, request, locals }) => {
const { slug, n } = params; const { slug, n } = params;
const chapter = parseInt(n, 10); const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) { if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number'); error(400, 'Invalid slug or chapter number');
} }
// ── Paywall: 3 audio chapters/day for free users ───────────────────────────
if (!locals.isPro) {
// Check if audio already exists (cached) before counting — no charge for
// re-requesting something already generated
const statusRes = await backendFetch(
`/api/audio/status/${slug}/${chapter}`
).catch(() => null);
const statusData = statusRes?.ok
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
: {};
if (statusData.status !== 'done') {
const identifier = locals.user?.id ?? locals.sessionId;
const count = await incrementDailyAudioCount(identifier);
if (count > FREE_DAILY_AUDIO_LIMIT) {
log.info('polar', 'free audio limit reached', { identifier, count });
return new Response(
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
let body: { voice?: string } = {}; let body: { voice?: string } = {};
try { try {
body = await request.json(); body = await request.json();
@@ -62,4 +115,3 @@ export const POST: RequestHandler = async ({ params, request }) => {
{ headers: { 'Content-Type': 'application/json' } } { headers: { 'Content-Type': 'application/json' } }
); );
}; };

View File

@@ -1,6 +1,7 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase'; import { getUserByUsername } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
/** /**
* GET /api/auth/me * GET /api/auth/me
@@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
} }
// Fetch full record from PocketBase to get avatar_url // Fetch full record from PocketBase to get avatar_url
const record = await getUserByUsername(locals.user.username).catch(() => null); const record = await getUserByUsername(locals.user.username).catch(() => null);
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
return json({ return json({
id: locals.user.id, id: locals.user.id,
username: locals.user.username, username: locals.user.username,
role: locals.user.role, role: locals.user.role,
avatar_url: record?.avatar_url ?? null avatar_url: avatarUrl
}); });
}; };

View File

@@ -4,6 +4,7 @@ import type { RequestHandler } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase'; import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger'; import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper'; import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
/** /**
* GET /api/chapter/[slug]/[n] * GET /api/chapter/[slug]/[n]
@@ -48,11 +49,11 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>' ? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: ''; : '';
let voices: string[] = []; let voices: Voice[] = [];
try { try {
const vRes = await backendFetch('/api/voices'); const vRes = await backendFetch('/api/voices');
if (vRes.ok) { if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] }; const d = (await vRes.json()) as { voices: Voice[] };
voices = d.voices ?? []; voices = d.voices ?? [];
} }
} catch { } catch {
@@ -85,10 +86,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const chapterIdx = chapters.find((c) => c.number === n); const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`); if (!chapterIdx) error(404, `Chapter ${n} not found`);
let voices: string[] = []; let voices: Voice[] = [];
try { try {
if (voicesRes?.ok) { if (voicesRes?.ok) {
const data = (await voicesRes.json()) as { voices: string[] }; const data = (await voicesRes.json()) as { voices: Voice[] };
voices = data.voices ?? []; voices = data.voices ?? [];
} }
} catch { } catch {

View File

@@ -5,9 +5,10 @@ import {
listReplies, listReplies,
createComment, createComment,
getMyVotes, getMyVotes,
getUserById,
type CommentSort type CommentSort
} from '$lib/server/pocketbase'; } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio'; import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger'; import { log } from '$lib/server/logger';
/** /**
@@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
replies: repliesPerComment[i] replies: repliesPerComment[i]
})); }));
// Batch-resolve avatar presign URLs for all unique user_ids // Batch-resolve avatar URLs for all unique user_ids
// MinIO first (custom upload), fall back to OAuth provider picture.
const allComments = [...topLevel, ...allReplies]; const allComments = [...topLevel, ...allReplies];
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))]; const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
const avatarEntries = await Promise.all( const avatarEntries = await Promise.all(
uniqueUserIds.map(async (userId) => { uniqueUserIds.map(async (userId) => {
try { try {
const url = await presignAvatarUrl(userId); const user = await getUserById(userId);
const url = await resolveAvatarUrl(userId, user?.avatar_url);
return [userId, url] as [string, string | null]; return [userId, url] as [string, string | null];
} catch { } catch {
return [userId, null] as [string, null]; return [userId, null] as [string, null];

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { presignAvatarUrl } from '$lib/server/minio'; import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase'; import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
import { backendFetch } from '$lib/server/scraper'; import { backendFetch } from '$lib/server/scraper';
@@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated'); if (!locals.user) error(401, 'Not authenticated');
const record = await getUserByUsername(locals.user.username).catch(() => null); const record = await getUserByUsername(locals.user.username).catch(() => null);
if (!record?.avatar_url) { const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
return json({ avatar_url: null });
}
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl }); return json({ avatar_url: avatarUrl });
}; };

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/** /**
* GET /api/settings * GET /api/settings
* Returns the current user's settings (auto_next, voice, speed). * Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
* Returns defaults if no settings record exists yet. * Returns defaults if no settings record exists yet.
*/ */
export const GET: RequestHandler = async ({ locals }) => { export const GET: RequestHandler = async ({ locals }) => {
@@ -14,7 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
return json({ return json({
autoNext: settings?.auto_next ?? false, autoNext: settings?.auto_next ?? false,
voice: settings?.voice ?? 'af_bella', voice: settings?.voice ?? 'af_bella',
speed: settings?.speed ?? 1.0 speed: settings?.speed ?? 1.0,
theme: settings?.theme ?? 'amber',
locale: settings?.locale ?? 'en',
fontFamily: settings?.font_family ?? 'system',
fontSize: settings?.font_size ?? 1.0
}); });
} catch (e) { } catch (e) {
log.error('settings', 'GET failed', { err: String(e) }); log.error('settings', 'GET failed', { err: String(e) });
@@ -24,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/** /**
* PUT /api/settings * PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number } * Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
* Saves user preferences. * Saves user preferences.
*/ */
export const PUT: RequestHandler = async ({ request, locals }) => { export const PUT: RequestHandler = async ({ request, locals }) => {
@@ -39,6 +43,30 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }'); error(400, 'Invalid body — expected { autoNext, voice, speed }');
} }
// theme is optional — if provided it must be a known value
const validThemes = ['amber', 'slate', 'rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt-BR', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}
try { try {
await saveSettings(locals.sessionId, body, locals.user?.id); await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,69 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
/**
* POST /api/translation/[slug]/[n]?lang=<lang>
* Proxy to backend translation enqueue endpoint.
* Enforces Pro gate — free users cannot enqueue translations.
*
* GET /api/translation/[slug]/[n]?lang=<lang>
* Proxy to backend translation fetch (no gate — already gated at page.server.ts).
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const lang = url.searchParams.get('lang') ?? '';
const res = await backendFetch(
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (!res.ok) {
return new Response(null, { status: res.status });
}
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};
export const POST: RequestHandler = async ({ params, url, locals }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
const lang = url.searchParams.get('lang') ?? '';
if (!slug || !chapter || chapter < 1) error(400, 'Invalid slug or chapter');
if (!SUPPORTED_LANGS.has(lang)) error(400, 'Unsupported language');
// ── Pro gate ──────────────────────────────────────────────────────────────
if (!locals.isPro) {
log.info('polar', 'translation blocked for free user', {
userId: locals.user?.id,
slug,
chapter,
lang
});
return new Response(
JSON.stringify({ error: 'pro_required' }),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}
const res = await backendFetch(
`/api/translation/${encodeURIComponent(slug)}/${chapter}?lang=${lang}`,
{ method: 'POST' }
);
if (!res.ok) {
const text = await res.text().catch(() => '');
log.error('translation', 'backend translation enqueue failed', { slug, chapter, lang, status: res.status, body: text });
error(res.status as Parameters<typeof error>[0], text || 'Translation enqueue failed');
}
const data = await res.json();
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
};

Some files were not shown because too many files have changed in this diff Show More