Compare commits

...

86 Commits

Author SHA1 Message Date
Admin
8588475d03 feat: in-reader settings, more like this, reading history, audio filter, feed page
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m20s
Release / Docker / runner (push) Successful in 2m28s
Release / Gitea Release (push) Has been skipped
- Add floating gear button + theme/font/size drawer in chapter reader
- Add 'More like this' horizontal scroll row on book detail page
- Add reading history tab on profile page (chronological progress timeline)
- Add audio-available badge + filter toggle on catalogue (GET /api/audio/slugs)
- Fix discover card entry animation pop (split entry vs snap-back cubic-bezier)
- Add dedicated /feed page showing books followed users are reading
- Add Feed nav link (desktop + mobile) in all 5 locales
2026-04-03 22:32:37 +05:00
Admin
28fee7aee3 feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated
sorting (only changes on metadata writes) with chapters_idx sorted
by -created, so the section now reflects actual chapter activity.

Add per-section show/hide toggles on the home page, persisted in
localStorage via Svelte 5 $state. Each section header gets a small
hide button; hidden sections appear as restore chips above the footer.
Toggleable: Recently Updated, Browse by Genre, From Following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:09:48 +05:00
Admin
a888d9a0f5 fix: clean up book detail mobile layout + make genre tags linkable
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.

- Split mobile CTAs into two clear rows:
  Row 1: primary read button(s) only (Continue / Start from ch.1)
  Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
  present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line

Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
2026-04-03 21:34:38 +05:00
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

- Add settingsDirty flag, set via setTimeout(0) after initial apply so
  the save effect is blocked for the first load and only runs on real
  user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
  validation as a defensive backstop
2026-04-03 21:07:01 +05:00
Admin
24d73cb730 fix: add device_fingerprint to PB schema + fix homelab Redis routing
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m33s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
OAuth login was silently failing: upsertUserSession() queried the
device_fingerprint column which didn't exist in the user_sessions
collection, PocketBase returned 400, the fallback authSessionId was
never written to the DB, and isSessionRevoked() immediately revoked
the cookie on first load after the OAuth redirect.

- scripts/pb-init-v3.sh: add device_fingerprint text field to the
  user_sessions create block (new installs) and add an idempotent
  add_field migration line (existing installs)

Audio jobs were stuck pending because the homelab runner was
connecting to its own local Redis instead of the prod VPS Redis.

- homelab/docker-compose.yml: change hardcoded REDIS_ADDR=redis:6379
  to ${REDIS_ADDR} so Doppler injects rediss://redis.libnovel.cc:6380
  (the Caddy TLS proxy that bridges the homelab runner to prod Redis)
2026-04-03 20:37:10 +05:00
Admin
19aeb90403 perf: cache home stats + ratings, fix discover card pop animation
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 48s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / backend (push) Successful in 2m52s
Release / Docker / ui (push) Successful in 2m4s
Release / Gitea Release (push) Successful in 21s
Cache home stats (10 min) and recently added books (5 min) to avoid
hitting PocketBase on every homepage load. Cache all ratings for
discovery ranking (5 min) with invalidation on setBookRating.
invalidateBooksCache now clears all related keys atomically.

Fix discover card pop-to-full-size bug: new card now transitions from
scale(0.95) to scale(1.0) matching its back-card position, instead of
snapping to full size instantly after each swipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:15:58 +05:00
Admin
06d4a7bfd4 feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s
**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq

**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts

**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic

**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 07:26:54 +05:00
Admin
73a92ccf8f fix: deduplicate sessions with device fingerprint upsert
All checks were successful
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / ui (push) Successful in 2m8s
Release / Docker / backend (push) Successful in 3m17s
Release / Docker / runner (push) Successful in 3m13s
Release / Gitea Release (push) Successful in 12s
OAuth callbacks were creating a new session record on every login from
the same device because user-agent/IP were hardcoded as empty strings,
producing a pile-up of 6+ identical 'Unknown browser' sessions.

- Add upsertUserSession(): looks up existing session by user_id +
  device_fingerprint (SHA-256 of ua::ip, first 16 hex chars); reuses
  and touches it (returning the same authSessionId) if found, creates
  a new record otherwise
- Add device_fingerprint field to UserSession interface
- Fix OAuth callback to read real user-agent/IP from request headers
  (they are available in RequestHandler via request.headers)
- Switch both OAuth and password login to upsertUserSession so the
  returned authSessionId is used for the auth token
- Extend pruneStaleUserSessions to also cap sessions at 10 per user
- Keep createUserSession as a deprecated shim for gradual migration
2026-04-02 22:22:17 +05:00
Admin
08361172c6 feat: ratings, shelves, sleep timer, EPUB export + fix TS errors
All checks were successful
Release / Docker / caddy (push) Successful in 38s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 57s
Release / Docker / ui (push) Successful in 1m49s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / backend (push) Successful in 3m46s
Release / Gitea Release (push) Successful in 13s
**Ratings (1–5 stars)**
- New `book_ratings` PB collection (session_id, user_id, slug, rating)
- `getBookRating`, `getBookAvgRating`, `setBookRating` in pocketbase.ts
- GET/POST /api/ratings/[slug] API route
- StarRating.svelte component with hover, animated stars, avg display
- Star rating shown on book detail page (desktop + mobile)

**Plan-to-Read shelf**
- `shelf` field added to `user_library` (reading/plan_to_read/completed/dropped)
- `updateBookShelf`, `getShelfMap` in pocketbase.ts
- PATCH /api/library/[slug] for shelf updates
- Shelf selector dropdown on book detail page (only when saved)
- Shelf tabs on library page to filter by category

**Sleep timer**
- `sleepUntil` state added to AudioStore
- Layout handles timer lifecycle (survives chapter navigation)
- Cycles Off → 15m → 30m → 45m → 60m → Off
- Shows live countdown in AudioPlayer when active

**EPUB export**
- Go backend: GET /api/export/{slug}?from=N&to=N
- Generates valid EPUB2 zip (mimetype uncompressed, OPF, NCX, XHTML chapters)
- Markdown → HTML via goldmark
- SvelteKit proxy at /api/export/[slug]
- Download button on book detail page (only when in library)

**Fix TS errors**
- discover/+page.svelte: currentBook possibly undefined (use {@const book})
- cardEl now $state for reactive binding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:50:04 +05:00
Admin
809dc8d898 fix: make asynq consumer actually claim and heartbeat translation tasks
All checks were successful
Release / Check ui (push) Successful in 27s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 1m3s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / runner (push) Successful in 3m23s
Release / Docker / backend (push) Successful in 4m26s
Release / Gitea Release (push) Successful in 13s
ClaimNextTranslationTask and HeartbeatTask were no-ops in the asynq
Consumer, so translation tasks created in PocketBase were never picked
up by the runner. Translation tasks live in PocketBase (not Redis),
so they must be claimed/heartbeated via the underlying pb consumer.
ReapStaleTasks is also delegated so stale translation tasks get reset.

Also removes the LibreTranslate healthcheck from homelab/runner
docker-compose.yml and relaxes depends_on to service_started — the
healthcheck was blocking runner startup until models loaded (~2 min)
and the models are already pre-downloaded in the volume.
2026-04-02 21:16:48 +05:00
Admin
e9c3426fbe feat: scroll active chapter into view when chapter drawer opens
All checks were successful
Release / Check ui (push) Successful in 40s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 3m26s
Release / Docker / backend (push) Successful in 4m3s
Release / Gitea Release (push) Successful in 12s
When the mini-player chapter drawer is opened, the current chapter is
now immediately scrolled into the center of the list instead of always
starting from the top. Uses a Svelte action (setIfActive) to track the
active chapter element and a $effect to call scrollIntoView on open.
2026-04-02 20:44:12 +05:00
Admin
8e611840d1 fix: add 30s timeout to PB HTTP client; halve heartbeat tick interval
All checks were successful
Release / Test backend (push) Successful in 32s
Release / Docker / caddy (push) Successful in 42s
Release / Check ui (push) Successful in 44s
Release / Docker / ui (push) Successful in 2m32s
Release / Docker / backend (push) Successful in 2m49s
Release / Docker / runner (push) Successful in 3m26s
Release / Gitea Release (push) Successful in 15s
- storage/pocketbase.go: replace http.DefaultClient (no timeout) with a
  dedicated pbHTTPClient{Timeout: 30s} so a slow/hung PocketBase cannot
  stall the backend or runner indefinitely
- runner/asynq_runner.go: heartbeat ticker was firing at StaleTaskThreshold
  (2 min) == the Docker healthcheck deadline, so a single missed tick would
  mark the container unhealthy; halved to StaleTaskThreshold/2 (1 min)
2026-04-02 18:49:04 +05:00
Admin
b9383570e3 ci: fix duplicate runs — ignore tag pushes and remove pull_request trigger
All checks were successful
Release / Test backend (push) Successful in 24s
Release / Docker / caddy (push) Successful in 50s
Release / Check ui (push) Successful in 57s
Release / Docker / backend (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m47s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 13s
2026-04-02 18:00:48 +05:00
Admin
eac9358c6f fix(discover): guard active card with {#if currentBook} to fix TS errors; use $state for cardEl bind
All checks were successful
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 26s
Release / Test backend (push) Successful in 23s
CI / UI (push) Successful in 39s
CI / Backend (push) Successful in 42s
Release / Check ui (push) Successful in 28s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m12s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / backend (push) Successful in 3m16s
Release / Gitea Release (push) Successful in 13s
2026-04-02 17:48:25 +05:00
Admin
9cb11bc5e4 chore(pb): add discovery_votes collection to pb-init script
Some checks failed
CI / UI (pull_request) Failing after 30s
CI / Backend (pull_request) Successful in 34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:38:15 +05:00
Admin
7196f8e930 feat(discover): Tinder-style book discovery + fix duplicate books
Some checks failed
CI / UI (push) Failing after 22s
CI / Backend (push) Successful in 53s
Release / Test backend (push) Successful in 24s
Release / Check ui (push) Failing after 24s
Release / Docker / ui (push) Has been skipped
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 22s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / backend (push) Successful in 1m38s
Release / Docker / runner (push) Successful in 3m14s
Release / Gitea Release (push) Has been skipped
- New /discover page with swipe UI: left=skip, right=like, up=read now, down=nope
- Onboarding modal to collect genre/status preferences (persisted in localStorage)
- 3-card stack with pointer-event drag, CSS fly-out animation, 5 action buttons
- Tap card for preview modal; empty state with deck reset
- Like/read-now auto-saves book to user library
- POST /api/discover/vote + DELETE for deck reset
- Discovery vote persistence via PocketBase discovery_votes collection
- Fix duplicate books: dedup by slug in getBooksBySlugs
- Fix WriteMetadata TOCTOU race: conflict-retry on concurrent insert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:33:27 +05:00
Admin
a771405db8 feat(audio): WAV streaming, bulk audio generation admin endpoints, cancel/resume
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 28s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 49s
Release / Docker / caddy (push) Successful in 59s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 46s
Release / Docker / ui (push) Successful in 1m31s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Failing after 32s
- Add StreamAudioWAV() to pocket-tts and Kokoro clients; pocket-tts streams
  raw WAV directly (no ffmpeg), Kokoro requests response_format:wav with stream:true
- GET /api/audio-stream supports ?format=wav for lower-latency first-byte delivery;
  WAV cached separately in MinIO as {slug}/{n}/{voice}.wav
- Add GET /api/admin/audio/jobs with optional ?slug filter
- Add POST /api/admin/audio/bulk {slug, voice, from, to, skip_existing, force}
  where skip_existing=true (default) resumes interrupted bulk jobs
- Add POST /api/admin/audio/cancel-bulk {slug} to cancel all pending/running tasks
- Add CancelAudioTasksBySlug to taskqueue.Producer + asynqqueue implementation
- Add AudioObjectKeyExt to bookstore.AudioStore for format-aware MinIO keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:19:14 +05:00
Admin
1e9a96aa0f fix(payments): fix TypeScript cast errors in polar webhook handler
Some checks failed
CI / UI (push) Successful in 25s
Release / Test backend (push) Successful in 22s
CI / Backend (push) Successful in 1m59s
Release / Check ui (push) Successful in 27s
Release / Docker / caddy (push) Successful in 44s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Failing after 1m26s
Release / Docker / runner (push) Successful in 1m40s
Release / Docker / backend (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 3m21s
Release / Gitea Release (push) Successful in 13s
Cast through unknown to satisfy TS strict overlap check for
PolarSubscription and PolarOrder types from Record<string, unknown>.
2026-03-31 23:40:11 +05:00
Admin
23ae1ed500 feat(payments): lock checkout email via Polar server-side checkout sessions
Some checks failed
CI / UI (pull_request) Failing after 23s
CI / Backend (push) Successful in 27s
CI / Backend (pull_request) Successful in 53s
CI / UI (push) Failing after 25s
Release / Test backend (push) Successful in 28s
Release / Check ui (push) Failing after 30s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 1m50s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
Replace static Polar checkout links with a server-side POST /api/checkout
route that creates a checkout session with customer_external_id = user ID
and customer_email locked (not editable). Adds loading/error states and
a post-checkout success banner on the profile page.
2026-03-31 23:36:53 +05:00
Admin
e7cb460f9b fix(payments): point manage subscription to org customer portal
Some checks failed
CI / Backend (pull_request) Successful in 32s
CI / UI (pull_request) Failing after 28s
CI / Backend (push) Successful in 26s
CI / UI (push) Failing after 25s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 47s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / runner (push) Successful in 2m0s
Release / Docker / backend (push) Successful in 2m59s
Release / Gitea Release (push) Has been skipped
2026-03-31 23:26:57 +05:00
Admin
392248e8a6 fix(payments): update Polar checkout links to use checkout link IDs
Some checks failed
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 16s
CI / UI (push) Failing after 17s
CI / Backend (push) Successful in 41s
Release / Test backend (push) Successful in 40s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / backend (push) Successful in 1m55s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
2026-03-31 23:25:26 +05:00
Admin
68ea2d2808 feat(payments): fix Polar webhook + pre-fill checkout email
Some checks failed
CI / Backend (pull_request) Successful in 26s
CI / UI (pull_request) Failing after 24s
CI / Backend (push) Successful in 26s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 31s
Release / Test backend (push) Successful in 41s
Release / Docker / caddy (push) Successful in 33s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / backend (push) Successful in 3m43s
Release / Gitea Release (push) Has been skipped
- Fix customer email path: was data.customer_email, is actually
  data.customer.email per Polar v1 API schema
- Add resolveUser() helper: tries polar_customer_id → email → external_id
- Add subscription.active and subscription.canceled event handling
- Handle order.created for fast-path pro upgrade on purchase
- Profile page: fetch user email + polarCustomerId from PocketBase
- Profile page: pre-fill ?customer_email= on checkout links
- Profile page: link to polar.sh/purchases for existing customers
2026-03-31 23:11:34 +05:00
Admin
7b1df9b592 fix(infra): fix libretranslate healthcheck; fix scrollbar-none css
All checks were successful
CI / Backend (push) Successful in 25s
Release / Test backend (push) Successful in 24s
CI / UI (push) Successful in 51s
Release / Check ui (push) Successful in 29s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Successful in 44s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / ui (push) Successful in 2m4s
Release / Docker / runner (push) Successful in 2m56s
Release / Docker / backend (push) Successful in 3m23s
Release / Gitea Release (push) Successful in 12s
2026-03-31 22:36:19 +05:00
Admin
f4089fe111 fix(admin): add layout guard and redirect /admin to /admin/scrape
All checks were successful
CI / Backend (push) Successful in 45s
CI / UI (push) Successful in 56s
Release / Check ui (push) Successful in 26s
Release / Test backend (push) Successful in 39s
CI / Backend (pull_request) Successful in 24s
Release / Docker / caddy (push) Successful in 48s
CI / UI (pull_request) Successful in 40s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / ui (push) Successful in 3m38s
Release / Gitea Release (push) Successful in 14s
- Add +layout.server.ts to enforce admin role check at layout level,
  preventing 404 on /admin and protecting all sub-routes centrally
- Add +page.server.ts to redirect /admin → /admin/scrape (was 404)
2026-03-31 22:33:39 +05:00
Admin
87b5ad1460 feat(auth): add debug-login bypass endpoint secured by DEBUG_LOGIN_TOKEN
All checks were successful
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 1m10s
Release / Check ui (push) Successful in 26s
Release / Test backend (push) Successful in 41s
CI / Backend (pull_request) Successful in 25s
Release / Docker / caddy (push) Successful in 47s
CI / UI (pull_request) Successful in 41s
Release / Docker / ui (push) Successful in 2m32s
Release / Docker / backend (push) Successful in 3m57s
Release / Docker / runner (push) Successful in 4m8s
Release / Gitea Release (push) Successful in 12s
2026-03-31 21:59:58 +05:00
Admin
168cb52ed0 fix(admin): use --color-surface for drawer bg (--color-bg was undefined)
Some checks failed
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 58s
Release / Check ui (push) Successful in 26s
Release / Test backend (push) Successful in 38s
Release / Docker / caddy (push) Successful in 45s
CI / Backend (pull_request) Successful in 26s
Release / Docker / ui (push) Failing after 10s
CI / UI (pull_request) Successful in 42s
Release / Docker / runner (push) Failing after 19s
Release / Docker / backend (push) Successful in 1m42s
Release / Gitea Release (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:53:58 +05:00
Admin
e1621a3ec2 fix(infra): move Redis to prod, fix LibreTranslate config loading
All checks were successful
CI / Backend (push) Successful in 46s
CI / UI (push) Successful in 52s
Release / Docker / caddy (push) Successful in 34s
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 49s
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Successful in 56s
Release / Docker / runner (push) Successful in 1m46s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / backend (push) Successful in 2m47s
Release / Gitea Release (push) Successful in 13s
- Add Redis sidecar to prod docker-compose; backend connects locally (redis:6379)
- Caddy layer4 now proxies redis.libnovel.cc:6380 → local redis:6379 (not homelab LAN)
- Remove HOMELAB_REDIS_ADDR; homelab runner connects out to prod Redis via rediss://
- Remove local Redis from homelab runner compose; drop redis_data volume
- Fix config.Load() missing LibreTranslate section — LIBRETRANSLATE_URL was never read
2026-03-31 18:26:32 +05:00
Admin
10c7a48bc6 fix(admin): move mobile nav toggle into content area to avoid z-index conflict
Some checks failed
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 59s
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 27s
Release / Docker / caddy (push) Failing after 39s
CI / UI (pull_request) Successful in 25s
CI / Backend (pull_request) Successful in 44s
Release / Docker / ui (push) Successful in 2m5s
Release / Docker / runner (push) Successful in 2m27s
Release / Docker / backend (push) Successful in 3m12s
Release / Gitea Release (push) Has been skipped
The fixed top bar was hidden behind the main site navbar (z-50).
Replace with an inline 'Admin menu' button at the top of the content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:07:28 +05:00
Admin
8b597c0bd2 fix(caddy): fix logo branding on all error pages
All checks were successful
Release / Test backend (push) Successful in 23s
CI / Backend (push) Successful in 41s
CI / UI (push) Successful in 49s
Release / Check ui (push) Successful in 27s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Successful in 47s
Release / Docker / caddy (push) Successful in 1m7s
Release / Docker / backend (push) Successful in 1m48s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 3m9s
Release / Gitea Release (push) Successful in 13s
Match main site logo style: lowercase 'libnovel' in full brand amber.
Add meta http-equiv refresh fallback for 5xx pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:39:38 +05:00
Admin
28cafe2aa8 fix(admin): make sidebar responsive on mobile with slide-out drawer
All checks were successful
CI / Backend (push) Successful in 50s
CI / UI (push) Successful in 25s
Release / Test backend (push) Successful in 39s
Release / Docker / caddy (push) Successful in 32s
Release / Check ui (push) Successful in 47s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Successful in 43s
Release / Docker / runner (push) Successful in 2m1s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / backend (push) Successful in 3m3s
Release / Gitea Release (push) Successful in 13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:33:17 +05:00
Admin
65f0425b61 fix(i18n): add _inputs param to admin_nav paraglide inner functions; exclude Go binaries from git
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 25s
CI / Backend (push) Successful in 26s
Release / Docker / caddy (push) Successful in 47s
CI / Backend (pull_request) Successful in 28s
CI / UI (push) Successful in 54s
CI / UI (pull_request) Successful in 40s
Release / Docker / backend (push) Successful in 2m1s
Release / Docker / ui (push) Successful in 2m14s
Release / Docker / runner (push) Successful in 3m37s
Release / Gitea Release (push) Successful in 22s
The admin_nav_* message files generated by paraglide used 0-param inner
functions but called them with inputs, causing 50 svelte-check type errors.
Add _inputs = {} to each inner locale function to match the call signature.

Also adds backend/backend and backend/runner to .gitignore — binaries are
built inside Dockerfile and should never be committed.
2026-03-31 10:53:44 +05:00
Admin
4e70a2981d fix(pipeline): add redis+libretranslate to homelab, make Asynq enqueue errors non-fatal
Some checks failed
CI / Backend (pull_request) Successful in 35s
CI / UI (push) Failing after 37s
CI / Backend (push) Successful in 53s
CI / UI (pull_request) Failing after 27s
- homelab/docker-compose.yml: add redis:7-alpine service (port 6379 bound to host
  so Caddy TLS proxy on prod can reach it), add libretranslate service, add
  redis_data and libretranslate_data volumes
- asynqqueue/producer.go: Asynq enqueue failures are now logged as warnings instead
  of returned as errors — PB record already exists so runner picks it up via poll
- backend/main.go: pass logger to NewProducer

Root cause: Redis was not reachable at 192.168.0.109:6379 because the redis
container had no host port binding. Caddy TLS proxy terminates TLS but could
not TCP-connect to the backend Redis.
2026-03-31 10:18:13 +05:00
Admin
004cb95e56 feat(i18n): translate admin sidebar nav labels (pages + tools)
Some checks failed
CI / Backend (pull_request) Successful in 49s
CI / UI (pull_request) Failing after 23s
Release / Test backend (push) Successful in 30s
CI / Backend (push) Successful in 1m0s
CI / UI (push) Failing after 45s
Release / Check ui (push) Failing after 19s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 1m21s
Release / Docker / runner (push) Successful in 2m40s
Release / Docker / backend (push) Successful in 3m28s
Release / Gitea Release (push) Has been skipped
2026-03-31 00:42:32 +05:00
Admin
aca649039c feat(ui): replace theme dots with dropdown, remove chevrons from lang and profile buttons
Some checks failed
CI / Backend (pull_request) Successful in 56s
CI / UI (pull_request) Successful in 29s
Release / Test backend (push) Successful in 27s
CI / Backend (push) Successful in 45s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 29s
Release / Docker / caddy (push) Successful in 58s
Release / Docker / backend (push) Successful in 3m33s
Release / Docker / ui (push) Successful in 1m52s
Release / Docker / runner (push) Failing after 11s
Release / Gitea Release (push) Has been skipped
2026-03-31 00:20:06 +05:00
Admin
8d95411139 fix(caddy): add SNI connection_policy to layer4 TLS block and anchor redis.libnovel.cc cert
Some checks failed
CI / Backend (pull_request) Successful in 30s
CI / UI (pull_request) Successful in 46s
Release / Test backend (push) Successful in 32s
CI / Backend (push) Successful in 49s
CI / UI (push) Successful in 57s
Release / Check ui (push) Successful in 31s
Release / Docker / caddy (push) Successful in 1m19s
Release / Docker / runner (push) Failing after 1m11s
Release / Docker / ui (push) Successful in 2m1s
Release / Docker / backend (push) Successful in 5m1s
Release / Gitea Release (push) Has been skipped
Without a connection_policy, Caddy resolved the TLS cert by the Docker
internal IP (172.18.0.5) instead of the hostname, causing TLS handshake
failures on :6380 (rediss:// from prod backend → homelab Redis / Asynq).

Changes:
- Caddyfile: add connection_policy { match { sni redis.libnovel.cc } } to
  the layer4 :6380 tls handler so Caddy picks the correct cert
- Caddyfile: add redis.libnovel.cc virtual-host block (respond 404) to
  force Caddy to obtain and cache a TLS cert for that hostname
- homelab/docker-compose.yml: add REDIS_ADDR, REDIS_PASSWORD,
  LIBRETRANSLATE_URL, LIBRETRANSLATE_API_KEY, and
  RUNNER_MAX_CONCURRENT_TRANSLATION to the runner service for parity with
  homelab/runner/docker-compose.yml
2026-03-31 00:02:01 +05:00
Admin
f9a4a0e416 fix: remove paraglideVitePlugin from vite.config — root cause of 500 errors
Some checks failed
CI / Backend (push) Failing after 11s
CI / UI (push) Successful in 45s
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Successful in 1m3s
Release / Docker / caddy (push) Successful in 55s
CI / Backend (pull_request) Successful in 48s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Failing after 11s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m20s
Release / Gitea Release (push) Has been skipped
The paraglideVitePlugin runs at build time (buildStart hook) and fetches
the inlang plugin from cdn.jsdelivr.net to recompile messages. This:
  1. Overwrites messages.js with 'export * as m from ...' unconditionally
  2. Causes Rollup SSR tree-shaking to replace all m.*() calls with (void 0)
  3. Crashes every page server-side with 'TypeError: (void 0) is not a function'

The plugin is no longer needed: compiled paraglide output is committed to
git and updated via 'npm run paraglide' when messages change. Removing the
plugin lets Vite treat messages.js as a plain static module, keeping all
exports intact through the SSR bundle.
2026-03-30 23:15:27 +05:00
Admin
a4d94f522a feat: styled error pages for all error surfaces
Some checks failed
CI / Backend (push) Failing after 11s
CI / UI (push) Successful in 51s
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m9s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 56s
Release / Docker / caddy (push) Successful in 1m22s
Release / Docker / backend (push) Failing after 1m46s
Release / Docker / ui (push) Successful in 2m32s
Release / Docker / runner (push) Successful in 3m35s
Release / Gitea Release (push) Has been skipped
- ui/src/error.html: custom SvelteKit last-resort fallback (replaces
  the bare '500 | Internal Error' shown when +error.svelte itself fails)
  — branded, auto-refreshes in 20s, book+lightning SVG illustration
- ui/src/routes/+error.svelte: improved with context-aware SVG
  illustrations (question mark book for 404, lightning bolt for 5xx),
  larger status watermark, and a Retry button on non-404 errors
- caddy/errors/500.html: new static error page matching the 502/503/504
  design — served by Caddy when a gateway-level 500 occurs
- Caddyfile: add handle_errors 500 block pointing at /srv/errors/500.html
- caddy/Dockerfile: COPY errors/ into image so static pages are baked in
2026-03-30 23:00:00 +05:00
Admin
34c8fab358 fix: commit all paraglide generated output files to git
All checks were successful
CI / Backend (push) Successful in 51s
CI / UI (push) Successful in 33s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 57s
Release / Docker / caddy (push) Successful in 36s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 1m8s
Release / Docker / backend (push) Successful in 2m9s
Release / Docker / runner (push) Successful in 3m5s
Release / Docker / ui (push) Successful in 2m44s
Release / Gitea Release (push) Successful in 29s
These files are needed at CI check time. paraglide fetches its plugin
from cdn.jsdelivr.net which is unavailable in the CI environment,
causing compile to produce empty output. Committing the generated
output means CI never needs to recompile them.
2026-03-30 22:29:32 +05:00
Admin
d54769ab12 fix: commit paraglide output to git; remove compile from prepare script
paraglide-js fetches its plugin from cdn.jsdelivr.net at compile time.
In CI (no outbound internet access), this silently produces an empty
_index.js, causing svelte-check to fail with 'not a module' errors.

Fix:
- Commit all generated src/lib/paraglide/ files to git (removing
  the auto-generated .gitignore that was hiding them)
- Simplify prepare script to only run svelte-kit sync
- Add separate 'npm run paraglide' script for developers to
  regenerate when messages/*.json source files change
2026-03-30 22:28:54 +05:00
Admin
d2a4edba43 fix: strip paraglide's 'export * as m' after compile in prepare script
Some checks failed
CI / Backend (pull_request) Successful in 54s
CI / UI (pull_request) Successful in 35s
Release / Test backend (push) Successful in 24s
CI / Backend (push) Successful in 1m2s
CI / UI (push) Successful in 1m0s
Release / Check ui (push) Failing after 29s
Release / Docker / ui (push) Has been skipped
Release / Docker / runner (push) Failing after 1m27s
Release / Docker / caddy (push) Successful in 1m31s
Release / Docker / backend (push) Failing after 1m31s
Release / Gitea Release (push) Has been skipped
paraglide-js unconditionally emits 'export * as m from ...' in messages.js
which causes Vite/Rollup SSR to tree-shake all named message imports,
replacing every m.*() call with (void 0)() and crashing every page.
Strip the two offending lines via a Node.js one-liner in the prepare script
so the fix survives every npm ci run in CI.

Also stop tracking messages.js in git since it is always regenerated.
2026-03-30 22:16:02 +05:00
Admin
4e7f8c6266 feat: streaming audio endpoint with MinIO write-through cache
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 55s
Release / Test backend (push) Successful in 56s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 1m6s
Release / Docker / caddy (push) Successful in 41s
CI / Backend (pull_request) Successful in 58s
CI / UI (pull_request) Successful in 55s
Release / Docker / runner (push) Failing after 55s
Release / Docker / backend (push) Failing after 1m23s
Release / Gitea Release (push) Has been skipped
Add GET /api/audio-stream/{slug}/{n}?voice= that streams MP3 audio to the
client as TTS generates it, while simultaneously uploading to MinIO. On
subsequent requests the endpoint redirects to the presigned MinIO URL,
skipping generation entirely.

- PocketTTS: StreamAudioMP3 pipes live WAV response body through ffmpeg
  (streaming transcode — no full-buffer wait)
- Kokoro: StreamAudioMP3 uses stream:true mode, returning MP3 frames
  directly without the two-step download-link flow
- AudioStore: PutAudioStream added for multipart MinIO upload from reader
- WriteTimeout bumped 60s → 15min to accommodate full-chapter streams
- X-Accel-Buffering: no header disables Caddy/nginx response buffering
2026-03-30 22:02:36 +05:00
Admin
b0a4cb8b3d fix: remove spurious 'export * as m' from messages.js causing all pages to 500
Some checks failed
CI / Backend (push) Successful in 1m9s
CI / UI (push) Successful in 34s
Release / Test backend (push) Successful in 39s
Release / Docker / caddy (push) Successful in 46s
CI / Backend (pull_request) Successful in 43s
Release / Check ui (push) Successful in 1m21s
CI / UI (pull_request) Successful in 34s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Failing after 3m8s
Release / Docker / runner (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
The paraglide messages.js had an extra 'export * as m from ...' line which
caused Rollup/Vite to tree-shake all actual message function imports in the
SSR bundle. Every m.* call compiled to (void 0)(), crashing every page
server-side with TypeError. Removed the duplicate namespace re-export.
2026-03-30 21:23:37 +05:00
Admin
f136ce6a60 fix: remove distinct background from error page status code box
Some checks failed
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 43s
CI / UI (push) Successful in 37s
CI / Backend (push) Successful in 46s
Release / Test backend (push) Successful in 53s
Release / Check ui (push) Successful in 44s
Release / Docker / backend (push) Failing after 43s
Release / Docker / caddy (push) Failing after 55s
Release / Docker / runner (push) Successful in 2m14s
Release / Docker / ui (push) Successful in 2m53s
Release / Gitea Release (push) Has been skipped
2026-03-30 20:34:14 +05:00
Admin
3bd1112a63 fix: remove default sort=-updated from listOne to prevent PocketBase 400 errors
All checks were successful
CI / Backend (push) Successful in 36s
CI / Backend (pull_request) Successful in 48s
CI / UI (push) Successful in 1m3s
CI / UI (pull_request) Successful in 37s
Collections without an 'updated' field (books, user_sessions, user_settings,
user_library) were returning 400 because listOne always sent sort=-updated.
Changed default to empty string since we only fetch 1 record (no ordering needed).
2026-03-30 20:32:38 +05:00
Admin
278e292956 fix(home): use book.summary instead of book.description in hero card
Some checks failed
CI / Backend (push) Successful in 1m3s
CI / UI (push) Successful in 40s
Release / Docker / caddy (push) Failing after 10s
Release / Test backend (push) Successful in 40s
CI / UI (pull_request) Successful in 41s
CI / Backend (pull_request) Successful in 59s
Release / Docker / runner (push) Failing after 38s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Successful in 1m1s
Release / Docker / ui (push) Successful in 2m50s
Release / Gitea Release (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:44:25 +05:00
Admin
76de5eb491 feat(reader): chapter comments + readers-this-week count
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Failing after 22s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 53s
Release / Docker / caddy (push) Successful in 48s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Failing after 44s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped
- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:34:12 +05:00
Admin
c6597c8d19 feat(home): hero resume card, horizontal scroll rows, genre strip, dedup
Some checks failed
CI / Backend (push) Successful in 1m0s
CI / UI (push) Failing after 26s
Release / Test backend (push) Successful in 53s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m13s
CI / UI (pull_request) Failing after 33s
Release / Docker / runner (push) Failing after 1m27s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Failing after 31s
Release / Docker / ui (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- First continue-reading book becomes a wide hero card with title,
  description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
  chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:20:28 +05:00
Admin
e8d7108753 feat(themes): add light, light-slate, and light-rose themes
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Failing after 11s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
CI / UI (push) Successful in 35s
Release / Docker / caddy (push) Successful in 58s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 31s
CI / UI (pull_request) Successful in 1m1s
Release / Docker / ui (push) Failing after 2m43s
Release / Gitea Release (push) Has been skipped
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:13:25 +05:00
Admin
90dbecfa17 feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
- Remove both "Save settings" buttons; all settings now auto-save with
  800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
  change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
  with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
  color) for cleaner readout
- Auto-advance toggle gains a subtitle description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:07:51 +05:00
Admin
2deb306419 fix(i18n+settings): rename pt-BR→pt, fix theme/locale persistence
Some checks failed
CI / Backend (push) Successful in 56s
CI / UI (push) Successful in 38s
Release / Test backend (push) Successful in 42s
Release / Docker / caddy (push) Failing after 11s
CI / Backend (pull_request) Failing after 11s
Release / Docker / backend (push) Failing after 38s
CI / UI (pull_request) Successful in 44s
Release / Check ui (push) Successful in 1m53s
Release / Docker / runner (push) Failing after 1m26s
Release / Docker / ui (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
Root cause: user_settings table was missing theme, locale, font_family,
font_size columns — PocketBase silently dropped them on every save.
Added the four columns via PocketBase API.

Also:
- listOne now sorts by -updated so the most-recent settings record wins
- PARAGLIDE_LOCALE cookie is now cleared when switching back to English
- pt-BR renamed to pt throughout (messages, inlang settings, validLocales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:05:14 +05:00
Admin
fd283bf6c6 fix(sessions): prune stale sessions on login to prevent accumulation
Some checks failed
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 36s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 40s
CI / UI (pull_request) Failing after 37s
CI / Backend (pull_request) Successful in 48s
Release / Docker / runner (push) Failing after 32s
Release / Docker / backend (push) Successful in 2m39s
Release / Docker / ui (push) Successful in 1m45s
Release / Gitea Release (push) Has been skipped
Sessions not seen in 30+ days are deleted in the background each time
a new session is created. No cron job needed — self-cleaning on login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:24:16 +05:00
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
529 changed files with 31851 additions and 2378 deletions

View File

@@ -2,20 +2,11 @@ name: CI
on:
push:
branches: ["main", "master"]
tags-ignore:
- "v*"
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
pull_request:
branches: ["main", "master"]
paths:
- "backend/**"
- "ui/**"
- "caddy/**"
- "docker-compose.yml"
- ".gitea/workflows/ci.yaml"
concurrency:
@@ -23,10 +14,13 @@ concurrency:
cancel-in-progress: true
jobs:
# ── backend: vet & test ───────────────────────────────────────────────────────
test-backend:
name: Test backend
# ── Go: vet + build + test ────────────────────────────────────────────────
backend:
name: Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
@@ -36,16 +30,23 @@ jobs:
cache-dependency-path: backend/go.sum
- name: go vet
working-directory: backend
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
working-directory: backend
run: go test -short -race -count=1 -timeout=60s ./...
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
# ── UI: type-check + build ────────────────────────────────────────────────
ui:
name: UI
runs-on: ubuntu-latest
defaults:
run:
@@ -67,57 +68,3 @@ jobs:
- name: 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
# ── ui: source map upload ─────────────────────────────────────────────────────
# Builds the UI with source maps and uploads them to GlitchTip so that error
# stack traces resolve to original .svelte/.ts file names and line numbers.
# Runs in parallel with docker-ui (both need check-ui to pass first).
upload-sourcemaps:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build with source maps
run: npm run build
- name: Download glitchtip-cli
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" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: libnovel-ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: libnovel-ui
# Commented out: GlitchTip project/auth token needs to be recreated after
# the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
# upload-sourcemaps:
# name: Upload source maps
# runs-on: ubuntu-latest
# needs: [check-ui]
# defaults:
# run:
# working-directory: ui
# steps:
# - uses: actions/checkout@v4
#
# - uses: actions/setup-node@v4
# with:
# node-version: "22"
# cache: npm
# cache-dependency-path: ui/package-lock.json
#
# - name: Install dependencies
# run: npm ci
#
# - name: Build with source maps
# run: npm run build
#
# - name: Download glitchtip-cli
# 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" \
# -o /usr/local/bin/glitchtip-cli
# chmod +x /usr/local/bin/glitchtip-cli
#
# - name: Inject debug IDs into build artifacts
# run: glitchtip-cli sourcemaps inject ./build
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
#
# - name: Upload source maps to GlitchTip
# run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
@@ -191,6 +190,17 @@ jobs:
steps:
- 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
- name: Log in to Docker Hub
@@ -219,6 +229,7 @@ jobs:
build-args: |
BUILD_VERSION=${{ steps.meta.outputs.version }}
BUILD_COMMIT=${{ gitea.sha }}
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
cache-to: type=inline
@@ -261,14 +272,14 @@ jobs:
release:
name: Gitea Release
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:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create release
uses: actions/gitea-release-action@v1
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
generate_release_notes: true

2
.gitignore vendored
View File

@@ -6,6 +6,8 @@
# ── Compiled binaries ──────────────────────────────────────────────────────────
backend/bin/
backend/backend
backend/runner
# ── Environment & secrets ──────────────────────────────────────────────────────
# Secrets are managed by Doppler — never commit .env files.

View File

@@ -56,6 +56,28 @@
ticker_interval 15s
}
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
# Exposes prod Redis over TLS for Asynq job enqueueing from the homelab runner.
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
# for redis.libnovel.cc; traffic is proxied to the local Redis sidecar.
# Requires the caddy-l4 module in the custom Caddy build.
layer4 {
:6380 {
route {
tls {
connection_policy {
match {
sni redis.libnovel.cc
}
}
}
proxy {
upstream redis:6379
}
}
}
}
}
(security_headers) {
header {
@@ -170,12 +192,36 @@
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
handle {
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 ───────────────────────────────────────────────
# These fire when the upstream (backend or ui) is completely unreachable.
# SvelteKit's own +error.svelte handles application-level errors (404, 500).
handle_errors 404 {
root * /srv/errors
rewrite * /404.html
file_server
}
handle_errors 500 {
root * /srv/errors
rewrite * /500.html
file_server
}
handle_errors 502 {
root * /srv/errors
rewrite * /502.html
file_server
@@ -235,26 +281,11 @@ search.libnovel.cc {
}
# ── Redis TLS cert anchor ─────────────────────────────────────────────────────
# The backend (prod) connects to rediss://redis.libnovel.cc:6380 to enqueue
# Asynq jobs. Caddy terminates TLS (Let's Encrypt cert for redis.libnovel.cc)
# and proxies the raw TCP stream to the homelab Redis via this reverse proxy.
#
# NOTE: Redis is NOT running on the prod server — it runs on the homelab
# (192.168.0.109:6379) and is exposed to the internet via this Caddy proxy.
# The homelab Redis is protected by REDIS_PASSWORD (requirepass).
#
# Caddy layer4 app handles this; requires the caddy-l4 module in the build.
{
layer4 {
redis.libnovel.cc:6380 {
route {
tls
proxy {
# Homelab Redis — replace with actual homelab IP or FQDN
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
}
}
}
}
}
# This virtual host exists solely so Caddy obtains and caches a TLS certificate
# for redis.libnovel.cc. The layer4 block above uses that cert to terminate TLS
# on :6380 (Asynq job-queue channel from prod → homelab Redis).
# The HTTP route itself just returns 404 — no real traffic expected here.
redis.libnovel.cc {
respond 404
}
}

View File

@@ -30,9 +30,14 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
-o /out/healthcheck ./cmd/healthcheck
# ── 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/backend /backend
USER appuser
ENTRYPOINT ["/backend"]
# ── runner service ───────────────────────────────────────────────────────────

Binary file not shown.

View File

@@ -15,6 +15,7 @@ package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -133,7 +134,7 @@ func run() error {
if parseErr != nil {
return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
}
asynqProducer := asynqqueue.NewProducer(store, redisOpt)
asynqProducer := asynqqueue.NewProducer(store, redisOpt, log)
defer asynqProducer.Close() //nolint:errcheck
producer = asynqProducer
log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
@@ -150,18 +151,19 @@ func run() error {
Commit: commit,
},
backend.Dependencies{
BookReader: store,
RankingStore: store,
AudioStore: store,
PresignStore: store,
ProgressStore: store,
CoverStore: store,
Producer: producer,
TaskReader: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
Log: log,
BookReader: store,
RankingStore: store,
AudioStore: store,
TranslationStore: store,
PresignStore: store,
ProgressStore: store,
CoverStore: store,
Producer: producer,
TaskReader: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
Log: log,
},
)
@@ -194,6 +196,14 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

View File

@@ -12,6 +12,7 @@ package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -24,6 +25,7 @@ import (
"github.com/libnovel/backend/internal/browser"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
"github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/novelfire"
"github.com/libnovel/backend/internal/otelsetup"
@@ -128,6 +130,14 @@ func run() error {
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 ─────────────────────────────────────────────────────────
var searchIndex meili.Client
if cfg.Meilisearch.URL != "" {
@@ -149,9 +159,11 @@ func run() error {
PollInterval: cfg.Runner.PollInterval,
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
MaxConcurrentTranslation: cfg.Runner.MaxConcurrentTranslation,
OrchestratorWorkers: workers,
MetricsAddr: cfg.Runner.MetricsAddr,
CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval,
CatalogueRequestDelay: cfg.Runner.CatalogueRequestDelay,
SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh,
RedisAddr: cfg.Redis.Addr,
RedisPassword: cfg.Redis.Password,
@@ -169,16 +181,18 @@ func run() error {
}
deps := runner.Dependencies{
Consumer: consumer,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
Log: log,
Consumer: consumer,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
LibreTranslate: ltClient,
Log: log,
}
r := runner.New(rCfg, deps)
@@ -209,6 +223,14 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

View File

@@ -43,6 +43,7 @@ require (
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/yuin/goldmark v1.8.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect

View File

@@ -84,6 +84,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
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/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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=

View File

@@ -10,14 +10,13 @@ import (
// 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
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
// delivers those tasks via the ServeMux handlers. However translation tasks
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
// still delegate to the underlying PocketBase consumer.
//
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
// are all no-ops here because Asynq owns those responsibilities.
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
// those responsibilities.
type Consumer struct {
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
}
@@ -37,6 +36,10 @@ func (c *Consumer) FinishAudioTask(ctx context.Context, id string, result domain
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)
}
@@ -51,6 +54,18 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
return domain.AudioTask{}, false, nil
}
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
// to pending and can be reclaimed.
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
return c.pb.ReapStaleTasks(ctx, staleAfter)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/taskqueue"
@@ -14,13 +15,15 @@ import (
type Producer struct {
pb taskqueue.Producer // underlying PocketBase producer
client *asynq.Client
log *slog.Logger
}
// NewProducer wraps an existing PocketBase Producer with Asynq dispatch.
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt) *Producer {
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt, log *slog.Logger) *Producer {
return &Producer{
pb: pb,
client: asynq.NewClient(redisOpt),
log: log,
}
}
@@ -49,7 +52,9 @@ func (p *Producer) CreateScrapeTask(ctx context.Context, kind, targetURL string,
}
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)
p.log.Warn("asynq enqueue scrape failed (task still in PB, runner will poll)",
"task_id", id, "err", err)
return id, nil
}
return id, nil
}
@@ -68,17 +73,32 @@ func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int
Voice: voice,
}
if err := p.enqueue(ctx, TypeAudioGenerate, payload); err != nil {
return id, fmt.Errorf("asynq enqueue audio (task still in PB): %w", err)
// Non-fatal: PB record exists; runner will pick it up on next poll.
p.log.Warn("asynq enqueue audio failed (task still in PB, runner will poll)",
"task_id", id, "err", err)
return id, nil
}
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)
}
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
// audio tasks for slug.
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
return p.pb.CancelAudioTasksBySlug(ctx, slug)
}
// enqueue serialises payload and dispatches it to Asynq.
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
b, err := json.Marshal(payload)

View File

@@ -0,0 +1,143 @@
package backend
import (
"archive/zip"
"bytes"
"fmt"
"strings"
)
type epubChapter struct {
Number int
Title string
HTML string
}
func generateEPUB(slug, title, author string, chapters []epubChapter) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// 1. mimetype — MUST be first, MUST be uncompressed (Store method)
mw, err := w.CreateHeader(&zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
})
if err != nil {
return nil, err
}
mw.Write([]byte("application/epub+zip"))
// 2. META-INF/container.xml
addFile(w, "META-INF/container.xml", containerXML())
// 3. OEBPS/style.css
addFile(w, "OEBPS/style.css", epubCSS())
// 4. OEBPS/content.opf
addFile(w, "OEBPS/content.opf", contentOPF(slug, title, author, chapters))
// 5. OEBPS/toc.ncx
addFile(w, "OEBPS/toc.ncx", tocNCX(slug, title, chapters))
// 6. Chapter files
for _, ch := range chapters {
name := fmt.Sprintf("OEBPS/chapter-%04d.xhtml", ch.Number)
addFile(w, name, chapterXHTML(ch))
}
w.Close()
return buf.Bytes(), nil
}
func addFile(w *zip.Writer, name, content string) {
f, _ := w.Create(name)
f.Write([]byte(content))
}
func containerXML() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`
}
func contentOPF(slug, title, author string, chapters []epubChapter) string {
var items, spine strings.Builder
for _, ch := range chapters {
id := fmt.Sprintf("ch%04d", ch.Number)
href := fmt.Sprintf("chapter-%04d.xhtml", ch.Number)
items.WriteString(fmt.Sprintf(` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, href))
spine.WriteString(fmt.Sprintf(` <itemref idref="%s"/>`+"\n", id))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>%s</dc:title>
<dc:creator>%s</dc:creator>
<dc:identifier id="uid">%s</dc:identifier>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="css" href="style.css" media-type="text/css"/>
%s </manifest>
<spine toc="ncx">
%s </spine>
</package>`, escapeXML(title), escapeXML(author), slug, items.String(), spine.String())
}
func tocNCX(slug, title string, chapters []epubChapter) string {
var points strings.Builder
for i, ch := range chapters {
chTitle := ch.Title
if chTitle == "" {
chTitle = fmt.Sprintf("Chapter %d", ch.Number)
}
points.WriteString(fmt.Sprintf(` <navPoint id="np%d" playOrder="%d">
<navLabel><text>%s</text></navLabel>
<content src="chapter-%04d.xhtml"/>
</navPoint>`+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head><meta name="dtb:uid" content="%s"/></head>
<docTitle><text>%s</text></docTitle>
<navMap>
%s </navMap>
</ncx>`, slug, escapeXML(title), points.String())
}
func chapterXHTML(ch epubChapter) string {
title := ch.Title
if title == "" {
title = fmt.Sprintf("Chapter %d", ch.Number)
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>%s</title><link rel="stylesheet" href="style.css"/></head>
<body>
<h1 class="chapter-title">%s</h1>
%s
</body>
</html>`, escapeXML(title), escapeXML(title), ch.HTML)
}
func epubCSS() string {
return `body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 1em 2em; }
h1.chapter-title { font-size: 1.4em; margin-bottom: 1em; }
p { margin: 0 0 0.8em 0; text-indent: 1.5em; }
p:first-of-type { text-indent: 0; }
`
}
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

View File

@@ -8,7 +8,7 @@ package backend
// handleBrowse, handleSearch
// handleGetRanking, handleGetCover
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
// handleVoices
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
// handlePresignAvatarUpload, handlePresignAvatar
@@ -32,6 +32,7 @@ package backend
// directly (no runner task, no store writes). Used for unscraped books.
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -49,6 +50,7 @@ import (
"github.com/libnovel/backend/internal/novelfire/htmlutil"
"github.com/libnovel/backend/internal/pockettts"
"github.com/libnovel/backend/internal/scraper"
"github.com/yuin/goldmark"
)
const (
@@ -701,9 +703,576 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, presignURL, http.StatusFound)
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// handleAudioStream handles GET /api/audio-stream/{slug}/{n}.
//
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
// (same as handleAudioProxy) — the client plays from storage immediately.
//
// Slow path (first request): streams audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, subsequent
// requests hit the fast path and skip TTS generation entirely.
//
// Query params:
//
// voice (optional, defaults to DefaultVoice)
// format (optional, "mp3" or "wav"; defaults to "mp3")
//
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
// raw WAV frames to the client with lower latency at the cost of larger files.
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
jsonError(w, http.StatusBadRequest, "invalid chapter")
return
}
// handleVoices handles GET /api/voices.
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
format := r.URL.Query().Get("format")
if format != "wav" {
format = "mp3"
}
contentType := "audio/mpeg"
if format == "wav" {
contentType = "audio/wav"
}
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
// ── Fast path: already in MinIO ───────────────────────────────────────────
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
if err != nil {
s.deps.Log.Error("handleAudioStream: PresignAudio failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
http.Redirect(w, r, presignURL, http.StatusFound)
return
}
// ── Slow path: generate + stream + save ───────────────────────────────────
// Read the chapter text.
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("handleAudioStream: ReadChapter failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusNotFound, "chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
return
}
// Open the TTS stream (WAV or MP3 depending on format param).
var audioStream io.ReadCloser
if format == "wav" {
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
}
} else {
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
}
}
if err != nil {
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
jsonError(w, http.StatusInternalServerError, "tts stream failed")
return
}
defer audioStream.Close()
// Tee: every byte read from audioStream is written to both the HTTP
// response and a pipe that feeds the MinIO upload goroutine.
pr, pw := io.Pipe()
// MinIO upload runs concurrently. Size -1 triggers multipart upload.
uploadDone := make(chan error, 1)
go func() {
uploadDone <- s.deps.AudioStore.PutAudioStream(
context.Background(), // use background — request ctx may cancel after client disconnects
audioKey, pr, -1, contentType,
)
}()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
tee := io.TeeReader(audioStream, pw)
buf := make([]byte, 32*1024)
for {
nr, readErr := tee.Read(buf)
if nr > 0 {
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
// Client disconnected — abort upload pipe so goroutine exits.
pw.CloseWithError(writeErr)
<-uploadDone
return
}
if canFlush {
flusher.Flush()
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
s.deps.Log.Warn("handleAudioStream: read error mid-stream", "err", readErr)
pw.CloseWithError(readErr)
<-uploadDone
return
}
}
// Signal end of stream to the MinIO upload goroutine.
pw.Close()
if uploadErr := <-uploadDone; uploadErr != nil {
s.deps.Log.Error("handleAudioStream: MinIO upload failed", "key", audioKey, "err", uploadErr)
// Audio was already streamed to the client — just log; don't error.
// The next request will re-stream since the object is absent.
}
// Note: we do not call FinishAudioTask here — the backend has no Consumer.
// handleAudioStatus fast-paths on AudioExists, so the UI will see "done"
// on its next poll as soon as the MinIO object is present.
}
// ── Translation ────────────────────────────────────────────────────────────────
// supportedTranslationLangs is the set of target locales the backend accepts.
// 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,
})
}
// ── Admin Audio ────────────────────────────────────────────────────────────────
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
// Returns all audio jobs, optionally filtered by slug (?slug=...).
// Sorted by started descending.
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
if err != nil {
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
return
}
// Optional slug filter.
slugFilter := r.URL.Query().Get("slug")
type jobRow struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
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 {
if slugFilter != "" && t.Slug != slugFilter {
continue
}
rows = append(rows, jobRow{
ID: t.ID,
CacheKey: t.CacheKey,
Slug: t.Slug,
Chapter: t.Chapter,
Voice: t.Voice,
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, "total": len(rows)})
}
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
//
// Enqueues one audio task per chapter in [from, to].
// skip_existing (default true): skip chapters already cached in MinIO — use this
// to resume a previously interrupted bulk job.
// force: if true, enqueue even when a pending/running task already exists.
// Max 1000 chapters per request.
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
Voice string `json:"voice"`
From int `json:"from"`
To int `json:"to"`
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
Force bool `json:"force"`
}
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 body.Voice == "" {
body.Voice = s.cfg.DefaultVoice
}
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
}
// skip_existing defaults to true (resume-friendly).
skipExisting := true
if body.SkipExisting != nil {
skipExisting = *body.SkipExisting
}
var taskIDs []string
skipped := 0
for n := body.From; n <= body.To; n++ {
// Skip chapters already cached in MinIO.
if skipExisting {
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
skipped++
continue
}
}
// Skip chapters with an active (pending/running) task unless force=true.
if !body.Force {
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
skipped++
continue
}
}
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
if err != nil {
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
"slug", body.Slug, "chapter", n, "voice", body.Voice, "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),
"skipped": skipped,
"task_ids": taskIDs,
})
}
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
// Body: {"slug": "..."}
// Cancels all pending and running audio tasks for the given slug.
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
}
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
}
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
if err != nil {
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
"slug", body.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
return
}
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
@@ -1160,6 +1729,109 @@ func stripMarkdown(src string) string {
return strings.TrimSpace(src)
}
// ── EPUB export ───────────────────────────────────────────────────────────────
// handleExportEPUB handles GET /api/export/{slug}.
// Generates and streams an EPUB file for the book identified by slug.
// Optional query params: from=N&to=N to limit the chapter range (default: all).
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
// Parse optional from/to range.
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
fromN, toN := 0, 0
if fromStr != "" {
v, err := strconv.Atoi(fromStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
return
}
fromN = v
}
if toStr != "" {
v, err := strconv.Atoi(toStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
return
}
toN = v
}
// Fetch book metadata for title and author.
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
if err != nil || !inLib {
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
jsonError(w, http.StatusNotFound, "book not found")
return
}
// List all chapters.
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
if err != nil {
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
return
}
// Filter chapters by from/to range.
var filtered []epubChapter
for _, ch := range chapters {
if fromN > 0 && ch.Number < fromN {
continue
}
if toN > 0 && ch.Number > toN {
continue
}
// Fetch markdown from MinIO.
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
if readErr != nil {
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
// Skip chapters that cannot be fetched.
continue
}
// Convert markdown to HTML using goldmark.
md := goldmark.New()
var htmlBuf bytes.Buffer
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
htmlBuf.Reset()
htmlBuf.WriteString("<p>" + mdText + "</p>")
}
filtered = append(filtered, epubChapter{
Number: ch.Number,
Title: ch.Title,
HTML: htmlBuf.String(),
})
}
if len(filtered) == 0 {
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
return
}
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
if err != nil {
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
return
}
w.Header().Set("Content-Type", "application/epub+zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
w.WriteHeader(http.StatusOK)
w.Write(epubBytes)
}
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when

View File

@@ -47,6 +47,8 @@ type Dependencies struct {
RankingStore bookstore.RankingStore
// AudioStore checks audio object existence and computes MinIO keys.
AudioStore bookstore.AudioStore
// TranslationStore checks translation existence and reads/writes translated markdown.
TranslationStore bookstore.TranslationStore
// PresignStore generates short-lived MinIO URLs.
PresignStore bookstore.PresignStore
// ProgressStore reads/writes per-session reading progress.
@@ -159,6 +161,23 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
// Streaming audio: serves from MinIO if cached, else streams live TTS
// while simultaneously uploading to MinIO for future requests.
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
// 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)
// Admin audio endpoints
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -171,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
// EPUB export
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
// Reading progress
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
@@ -188,7 +210,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
Addr: s.cfg.Addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
IdleTimeout: 60 * time.Second,
}

View File

@@ -14,6 +14,7 @@ package bookstore
import (
"context"
"io"
"time"
"github.com/libnovel/backend/internal/domain"
@@ -79,14 +80,24 @@ type RankingStore interface {
// AudioStore covers audio object storage (runner writes; backend reads).
type AudioStore interface {
// AudioObjectKey returns the MinIO object key for a cached audio file.
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
// Format: {slug}/{n}/{voice}.mp3
AudioObjectKey(slug string, n int, voice string) string
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
// with a custom extension (e.g. "mp3" or "wav").
AudioObjectKeyExt(slug string, n int, voice, ext string) string
// AudioExists returns true when the audio object is present in MinIO.
AudioExists(ctx context.Context, key string) bool
// PutAudio stores raw audio bytes under the given MinIO object key.
PutAudio(ctx context.Context, key string, data []byte) error
// PutAudioStream uploads audio from r to MinIO under key.
// size must be the exact byte length of r, or -1 to use multipart upload.
// contentType should be "audio/mpeg" or "audio/wav".
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
}
// PresignStore generates short-lived URLs — used exclusively by the backend.
@@ -141,3 +152,19 @@ type CoverStore interface {
// CoverExists returns true when a cover image is stored for slug.
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

@@ -2,6 +2,7 @@ package bookstore_test
import (
"context"
"io"
"testing"
"time"
@@ -51,9 +52,13 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}
// PresignStore
func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {

View File

@@ -46,6 +46,8 @@ type MinIO struct {
BucketAvatars string
// BucketBrowse is the bucket that holds cached browse page snapshots (JSON).
BucketBrowse string
// BucketTranslations is the bucket that holds machine-translated chapter markdown.
BucketTranslations string
}
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
@@ -64,6 +66,16 @@ type PocketTTS struct {
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).
type HTTP struct {
// Addr is the listen address, e.g. ":8080"
@@ -107,6 +119,8 @@ type Runner struct {
MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int
// MaxConcurrentTranslation limits simultaneous translation goroutines.
MaxConcurrentTranslation int
// WorkerID is a unique identifier for this runner instance.
// Defaults to the system hostname.
WorkerID string
@@ -126,19 +140,25 @@ type Runner struct {
// is already indexed and a 24h walk would be wasteful.
// Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true.
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.
type Config struct {
PocketBase PocketBase
MinIO MinIO
Kokoro Kokoro
PocketTTS PocketTTS
HTTP HTTP
Runner Runner
Meilisearch Meilisearch
Valkey Valkey
Redis Redis
PocketBase PocketBase
MinIO MinIO
Kokoro Kokoro
PocketTTS PocketTTS
LibreTranslate LibreTranslate
HTTP HTTP
Runner Runner
Meilisearch Meilisearch
Valkey Valkey
Redis Redis
// LogLevel is one of "debug", "info", "warn", "error".
LogLevel string
}
@@ -161,16 +181,17 @@ func Load() Config {
},
MinIO: MinIO{
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
UseSSL: envBool("MINIO_USE_SSL", false),
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
UseSSL: envBool("MINIO_USE_SSL", false),
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
BucketTranslations: envOr("MINIO_BUCKET_TRANSLATIONS", "translations"),
},
Kokoro: Kokoro{
@@ -182,6 +203,11 @@ func Load() Config {
URL: envOr("POCKET_TTS_URL", ""),
},
LibreTranslate: LibreTranslate{
URL: envOr("LIBRETRANSLATE_URL", ""),
APIKey: envOr("LIBRETRANSLATE_API_KEY", ""),
},
HTTP: HTTP{
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
},
@@ -190,12 +216,14 @@ func Load() Config {
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
MaxConcurrentTranslation: envInt("RUNNER_MAX_CONCURRENT_TRANSLATION", 1),
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false),
CatalogueRequestDelay: envDuration("RUNNER_CATALOGUE_REQUEST_DELAY", 2*time.Second),
},
Meilisearch: Meilisearch{

View File

@@ -149,3 +149,23 @@ type AudioResult struct {
ObjectKey string `json:"object_key,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

@@ -21,6 +21,17 @@ type Client interface {
// GenerateAudio synthesises text using voice and returns raw MP3 bytes.
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
// StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams
// MP3-encoded audio incrementally. Uses the kokoro-fastapi streaming mode
// (stream:true), which delivers MP3 frames as they are generated without
// waiting for the full output. The caller must always close the ReadCloser.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
// response_format:"wav". The caller must always close the ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns the available voice IDs. Falls back to an empty slice
// on error — callers should treat an empty list as "service unavailable".
ListVoices(ctx context.Context) ([]string, error)
@@ -118,6 +129,90 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
return data, nil
}
// StreamAudioMP3 calls POST /v1/audio/speech with stream:true and returns an
// io.ReadCloser that delivers MP3 frames as kokoro generates them.
// kokoro-fastapi emits raw MP3 bytes when stream mode is enabled — no download
// redirect; the response body IS the audio stream.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("kokoro: empty text")
}
if voice == "" {
voice = "af_bella"
}
reqBody, err := json.Marshal(map[string]any{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "mp3",
"speed": 1.0,
"stream": true,
})
if err != nil {
return nil, fmt.Errorf("kokoro: marshal stream request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("kokoro: build stream request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro: stream request: %w", err)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("kokoro: stream returned %d", resp.StatusCode)
}
return resp.Body, nil
}
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("kokoro: empty text")
}
if voice == "" {
voice = "af_bella"
}
reqBody, err := json.Marshal(map[string]any{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "wav",
"speed": 1.0,
"stream": true,
})
if err != nil {
return nil, fmt.Errorf("kokoro: marshal wav stream request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("kokoro: build wav stream request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro: wav stream request: %w", err)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("kokoro: wav stream returned %d", resp.StatusCode)
}
return resp.Body, nil
}
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,

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"
"fmt"
"log/slog"
"math/rand"
"net/url"
"path"
"strconv"
@@ -55,6 +56,9 @@ func (s *Scraper) SourceName() string { return "novelfire.net" }
// ── CatalogueProvider ─────────────────────────────────────────────────────────
// 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) {
entries := make(chan domain.CatalogueEntry, 64)
errs := make(chan error, 16)
@@ -73,8 +77,18 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
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)
raw, err := s.client.GetContent(ctx, pageURL)
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 10*time.Second)
if err != nil {
errs <- fmt.Errorf("catalogue page %d: %w", page, err)
return
@@ -139,10 +153,11 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
// ── MetadataProvider ──────────────────────────────────────────────────────────
// 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) {
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 {
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
if genresNode != nil {
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
if categoriesNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "categories"}); categoriesNode != nil {
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"})

View File

@@ -2,6 +2,7 @@ package novelfire
import (
"context"
"log/slog"
"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 ─────────────────────────────────────────────
type stubClient struct {

View File

@@ -9,6 +9,10 @@
// so callers receive MP3 bytes — the same format as the kokoro client — and the
// rest of the pipeline does not need to care which TTS engine was used.
//
// StreamAudioMP3 is the streaming variant: it returns an io.ReadCloser that
// yields MP3-encoded audio incrementally as pocket-tts generates it, without
// buffering the full output.
//
// Predefined voices (pass the bare name as the voice parameter):
//
// alba, marius, javert, jean, fantine, cosette, eponine, azelma,
@@ -50,6 +54,17 @@ type Client interface {
// Voice must be one of the predefined pocket-tts voice names.
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
// StreamAudioMP3 synthesises text and returns an io.ReadCloser that streams
// MP3-encoded audio incrementally via a live ffmpeg transcode pipe.
// The caller must always close the returned ReadCloser.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
// raw WAV audio directly from pocket-tts without any transcoding.
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
// The caller must always close the returned ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns the available predefined voice names.
ListVoices(ctx context.Context) ([]string, error)
}
@@ -79,14 +94,116 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
voice = "alba"
}
// ── Build multipart form ──────────────────────────────────────────────────
resp, err := c.postTTS(ctx, text, voice)
if err != nil {
return nil, err
}
defer resp.Body.Close()
wavData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("pockettts: read response body: %w", err)
}
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
mp3Data, err := wavToMP3(ctx, wavData)
if err != nil {
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
}
return mp3Data, nil
}
// StreamAudioMP3 posts to POST /tts and returns an io.ReadCloser that delivers
// MP3 bytes as pocket-tts generates WAV frames. ffmpeg runs as a subprocess
// with stdin connected to the live WAV stream and stdout piped to the caller.
// The caller must always close the returned ReadCloser.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("pockettts: empty text")
}
if voice == "" {
voice = "alba"
}
resp, err := c.postTTS(ctx, text, voice)
if err != nil {
return nil, err
}
// Start ffmpeg: read WAV from stdin (the live HTTP body), write MP3 to stdout.
cmd := exec.CommandContext(ctx,
"ffmpeg",
"-hide_banner", "-loglevel", "error",
"-i", "pipe:0", // WAV from stdin
"-f", "mp3", // output format
"-q:a", "2", // VBR ~190 kbps
"pipe:1", // MP3 to stdout
)
cmd.Stdin = resp.Body
pr, pw := io.Pipe()
cmd.Stdout = pw
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("pockettts: start ffmpeg: %w", err)
}
// Close the write end of the pipe when ffmpeg exits, propagating any error.
go func() {
waitErr := cmd.Wait()
resp.Body.Close()
if waitErr != nil {
pw.CloseWithError(fmt.Errorf("ffmpeg: %w (stderr: %s)", waitErr, stderrBuf.String()))
} else {
pw.Close()
}
}()
return pr, nil
}
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
// The caller must always close the returned ReadCloser.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("pockettts: empty text")
}
if voice == "" {
voice = "alba"
}
resp, err := c.postTTS(ctx, text, voice)
if err != nil {
return nil, err
}
return resp.Body, nil
}
// ListVoices returns the statically known predefined voice names.
// pocket-tts has no REST endpoint for listing voices.
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
voices := make([]string, 0, len(PredefinedVoices))
for v := range PredefinedVoices {
voices = append(voices, v)
}
return voices, nil
}
// postTTS sends a multipart POST /tts request and returns the raw response.
// The caller is responsible for closing resp.Body.
func (c *httpClient) postTTS(ctx context.Context, text, voice string) (*http.Response, error) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.WriteField("text", text); err != nil {
return nil, fmt.Errorf("pockettts: write text field: %w", err)
}
// pocket-tts accepts a predefined voice name as voice_url.
if err := mw.WriteField("voice_url", voice); err != nil {
return nil, fmt.Errorf("pockettts: write voice_url field: %w", err)
}
@@ -105,34 +222,12 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
if err != nil {
return nil, fmt.Errorf("pockettts: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("pockettts: server returned %d", resp.StatusCode)
}
wavData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("pockettts: read response body: %w", err)
}
// ── Transcode WAV → MP3 via ffmpeg ────────────────────────────────────────
mp3Data, err := wavToMP3(ctx, wavData)
if err != nil {
return nil, fmt.Errorf("pockettts: transcode to mp3: %w", err)
}
return mp3Data, nil
}
// ListVoices returns the statically known predefined voice names.
// pocket-tts has no REST endpoint for listing voices.
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
voices := make([]string, 0, len(PredefinedVoices))
for v := range PredefinedVoices {
voices = append(voices, v)
}
return voices, nil
return resp, nil
}
// wavToMP3 converts raw WAV bytes to MP3 using ffmpeg.

View File

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

View File

@@ -29,6 +29,7 @@ import (
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
"github.com/libnovel/backend/internal/meili"
"github.com/libnovel/backend/internal/orchestrator"
"github.com/libnovel/backend/internal/pockettts"
@@ -48,6 +49,8 @@ type Config struct {
MaxConcurrentScrape int
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
MaxConcurrentAudio int
// MaxConcurrentTranslation limits simultaneous translation goroutines.
MaxConcurrentTranslation int
// OrchestratorWorkers is the chapter-scraping parallelism inside each book run.
OrchestratorWorkers int
// HeartbeatInterval is how often active tasks PATCH their heartbeat_at
@@ -62,6 +65,10 @@ type Config struct {
// scrapes per-book metadata, downloads covers, and re-indexes everything in
// Meilisearch. Defaults to 24h (expensive — full catalogue walk).
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
// otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval)
// still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for
@@ -91,6 +98,8 @@ type Dependencies struct {
BookReader bookstore.BookReader
// AudioStore persists generated audio and checks key existence.
AudioStore bookstore.AudioStore
// TranslationStore persists translated markdown and checks key existence.
TranslationStore bookstore.TranslationStore
// CoverStore stores book cover images in MinIO.
CoverStore bookstore.CoverStore
// SearchIndex indexes books in Meilisearch after scraping.
@@ -103,6 +112,9 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
// If nil, pocket-tts voice tasks will fail with a clear error.
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 *slog.Logger
}
@@ -133,6 +145,9 @@ func New(cfg Config, deps Dependencies) *Runner {
if cfg.MaxConcurrentAudio <= 0 {
cfg.MaxConcurrentAudio = 1
}
if cfg.MaxConcurrentTranslation <= 0 {
cfg.MaxConcurrentTranslation = 1
}
if cfg.WorkerID == "" {
cfg.WorkerID = "runner"
}
@@ -145,6 +160,9 @@ func New(cfg Config, deps Dependencies) *Runner {
if cfg.CatalogueRefreshInterval <= 0 {
cfg.CatalogueRefreshInterval = 24 * time.Hour
}
if cfg.CatalogueRequestDelay <= 0 {
cfg.CatalogueRequestDelay = 2 * time.Second
}
if cfg.MetricsAddr == "" {
cfg.MetricsAddr = ":9091"
}
@@ -168,6 +186,7 @@ func (r *Runner) Run(ctx context.Context) error {
"mode", r.mode(),
"max_scrape", r.cfg.MaxConcurrentScrape,
"max_audio", r.cfg.MaxConcurrentAudio,
"max_translation", r.cfg.MaxConcurrentTranslation,
"catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval,
"metrics_addr", r.cfg.MetricsAddr,
)
@@ -201,6 +220,7 @@ func (r *Runner) mode() string {
func (r *Runner) runPoll(ctx context.Context) error {
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
var wg sync.WaitGroup
tick := time.NewTicker(r.cfg.PollInterval)
@@ -220,7 +240,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
// Run one poll immediately on startup, then on each tick.
for {
r.poll(ctx, scrapeSem, audioSem, &wg)
r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
select {
case <-ctx.Done():
@@ -245,7 +265,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
}
// 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 ────────────────────────────────────────────────────
// Touch /tmp/runner.alive so the Docker health check can confirm the
// runner is actively polling. Failure is non-fatal — just log it.
@@ -328,6 +348,39 @@ audioLoop:
r.runAudioTask(ctx, t)
}(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.

View File

@@ -1,8 +1,10 @@
package runner_test
import (
"bytes"
"context"
"errors"
"io"
"sync/atomic"
"testing"
"time"
@@ -48,6 +50,10 @@ func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.A
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 {
s.finished = append(s.finished, id)
return nil
@@ -58,6 +64,11 @@ func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.Au
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 {
s.failCalled = append(s.failCalled, id)
return nil
@@ -115,11 +126,18 @@ type stubAudioStore struct {
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
}
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
}
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
s.putCalled.Add(1)
return s.putErr
}
func (s *stubAudioStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
s.putCalled.Add(1)
return s.putErr
}
// stubNovelScraper satisfies scraper.NovelScraper minimally.
type stubNovelScraper struct {
@@ -176,6 +194,22 @@ func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return s.data, s.genErr
}
func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
s.called.Add(1)
if s.genErr != nil {
return nil, s.genErr
}
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
s.called.Add(1)
if s.genErr != nil {
return nil, s.genErr
}
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
return []string{"af_bella"}, 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.
type minioClient struct {
client *minio.Client // internal — all read/write operations
pubClient *minio.Client // presign-only — initialised against the public endpoint
bucketChapters string
bucketAudio string
bucketAvatars string
bucketBrowse string
client *minio.Client // internal — all read/write operations
pubClient *minio.Client // presign-only — initialised against the public endpoint
bucketChapters string
bucketAudio string
bucketAvatars string
bucketBrowse string
bucketTranslations string
}
func newMinioClient(cfg config.MinIO) (*minioClient, error) {
@@ -74,18 +75,19 @@ func newMinioClient(cfg config.MinIO) (*minioClient, error) {
}
return &minioClient{
client: internal,
pubClient: pub,
bucketChapters: cfg.BucketChapters,
bucketAudio: cfg.BucketAudio,
bucketAvatars: cfg.BucketAvatars,
bucketBrowse: cfg.BucketBrowse,
client: internal,
pubClient: pub,
bucketChapters: cfg.BucketChapters,
bucketAudio: cfg.BucketAudio,
bucketAvatars: cfg.BucketAvatars,
bucketBrowse: cfg.BucketBrowse,
bucketTranslations: cfg.BucketTranslations,
}, nil
}
// ensureBuckets creates all required buckets if they don't already exist.
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)
if err != nil {
return fmt.Errorf("minio: check bucket %q: %w", bucket, err)
@@ -107,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
}
// AudioObjectKey returns the MinIO object key for a cached audio file.
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
// with a custom extension (e.g. "mp3" or "wav").
// Format: {slug}/{n}/{voice}.{ext}
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
}
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
// Format: {slug}/{n}/{voice}.mp3
func AudioObjectKey(slug string, n int, voice string) string {
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
return AudioObjectKeyExt(slug, n, voice, "mp3")
}
// AvatarObjectKey returns the MinIO object key for a user avatar image.
@@ -125,6 +134,12 @@ func CoverObjectKey(slug string) string {
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.
// e.g. "my-book/chapter-000042.md" → 42
func chapterNumberFromKey(key string) int {
@@ -147,6 +162,14 @@ func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType st
return err
}
// putObjectStream uploads from r with known size (or -1 for multipart).
func (m *minioClient) putObjectStream(ctx context.Context, bucket, key, contentType string, r io.Reader, size int64) error {
_, err := m.client.PutObject(ctx, bucket, key, r, size,
minio.PutObjectOptions{ContentType: contentType},
)
return err
}
func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) {
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
if err != nil {

View File

@@ -26,6 +26,11 @@ import (
// ErrNotFound is returned by single-record lookups when no record exists.
var ErrNotFound = errors.New("storage: record not found")
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
// hung PocketBase never stalls the backend/runner process indefinitely.
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
// pbClient is the internal PocketBase REST admin client.
type pbClient struct {
baseURL string
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("pb auth: %w", err)
}
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"strings"
"time"
@@ -51,6 +52,7 @@ var _ bookstore.AudioStore = (*Store)(nil)
var _ bookstore.PresignStore = (*Store)(nil)
var _ bookstore.ProgressStore = (*Store)(nil)
var _ bookstore.CoverStore = (*Store)(nil)
var _ bookstore.TranslationStore = (*Store)(nil)
var _ taskqueue.Producer = (*Store)(nil)
var _ taskqueue.Consumer = (*Store)(nil)
var _ taskqueue.Reader = (*Store)(nil)
@@ -72,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
"rating": meta.Rating,
}
// Upsert via filter: if exists PATCH, otherwise POST.
// Use a conflict-retry pattern to handle concurrent scrapes racing to insert
// the same slug: if POST fails (or another concurrent writer beat us to it),
// re-fetch and PATCH instead.
existing, err := s.getBookBySlug(ctx, meta.Slug)
if err != nil && err != ErrNotFound {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
if postErr == nil {
return nil
}
// POST failed — a concurrent writer may have inserted the same slug.
// Re-fetch and fall through to PATCH.
existing, err = s.getBookBySlug(ctx, meta.Slug)
if err != nil {
return postErr // original POST error is more informative
}
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
}
@@ -374,6 +388,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
return AudioObjectKey(slug, n, voice)
}
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return AudioObjectKeyExt(slug, n, voice, ext)
}
func (s *Store) AudioExists(ctx context.Context, key string) bool {
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
}
@@ -382,6 +400,10 @@ func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
}
func (s *Store) PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
return s.mc.putObjectStream(ctx, s.mc.bucketAudio, key, contentType, r, size)
}
// ── PresignStore ──────────────────────────────────────────────────────────────
func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
@@ -535,16 +557,61 @@ func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, v
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 {
// 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),
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == 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)})
}
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
if err != nil {
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
}
cancelled := 0
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
if patchErr := s.pb.patch(ctx,
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
cancelled++
}
}
}
return cancelled, nil
}
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
@@ -571,6 +638,18 @@ func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain
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 {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
@@ -599,6 +678,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 {
payload := map[string]any{
"status": string(domain.TaskStatusFailed),
@@ -608,11 +699,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 {
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.
// 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 {
payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
@@ -620,7 +714,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 {
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
@@ -638,7 +735,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
}
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, "")
if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
@@ -715,6 +812,31 @@ func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.Audio
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 ───────────────────────────────────────────────────────────────────
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
@@ -789,6 +911,38 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
}, 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 ─────────────────────────────────────────────────────────────────
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
@@ -818,3 +972,25 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
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,9 +29,17 @@ type Producer interface {
// returns the assigned PocketBase record ID.
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.
// Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
// Returns the number of tasks cancelled.
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
}
// Consumer is the read/claim side of the task queue used by the runner.
@@ -46,13 +54,21 @@ type Consumer interface {
// Returns (zero, false, nil) when the queue is empty.
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(ctx context.Context, id string, result domain.ScrapeResult) error
// FinishAudioTask marks a running audio task as done and records the result.
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
// HeartbeatTask updates the heartbeat_at timestamp on a running task.
@@ -81,4 +97,11 @@ type Reader interface {
// GetAudioTask returns the most recent audio task for cacheKey.
// Returns (zero, false, nil) if not found.
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,7 +23,11 @@ func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "audio-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return 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) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
@@ -31,12 +35,18 @@ func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.Scr
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
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 {
return nil
}
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
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) HeartbeatTask(_ context.Context, _ string) error { return nil }
@@ -53,6 +63,12 @@ func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
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.
var _ taskqueue.Producer = (*stubStore)(nil)

View File

@@ -7,3 +7,4 @@ RUN xcaddy build \
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY errors/ /srv/errors/

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

@@ -0,0 +1,138 @@
<!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: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
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="/">libnovel</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>

203
caddy/errors/500.html Normal file
View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>500 — Internal Error — libnovel</title>
<meta http-equiv="refresh" content="20">
<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: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.illustration {
width: 96px;
height: 96px;
margin-bottom: 2rem;
}
.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: #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;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.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; }
.btn-secondary {
background: transparent;
color: #a1a1aa;
border: 1px solid #27272a;
cursor: pointer;
}
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
.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;
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">libnovel</a>
</header>
<main>
<!-- Book with lightning bolt SVG -->
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Book cover -->
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Spine -->
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Pages edge -->
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
<!-- Lightning bolt -->
<path d="M44 22 L34 46 H42 L36 70 L58 42 H48 L56 22 Z" fill="#f59e0b" opacity="0.9"/>
<!-- Text lines -->
<rect x="22" y="58" width="28" height="2.5" rx="1.25" fill="#3f3f46"/>
<rect x="22" y="63" width="18" height="2.5" rx="1.25" fill="#3f3f46"/>
<rect x="22" y="68" width="24" height="2.5" rx="1.25" fill="#3f3f46"/>
</svg>
<div class="watermark">500</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Internal error</span>
</div>
<h1>Something went wrong</h1>
<p>An unexpected error occurred on our end. We're on it — try again in a moment.</p>
<div class="actions">
<a class="btn" href="/">Go home</a>
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
</div>
<p class="refresh-note">Auto-refreshing 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>
</html>

View File

@@ -3,49 +3,160 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>502 — Service Unavailable</title>
<title>502 — Service Unavailable — libnovel</title>
<meta http-equiv="refresh" content="20">
<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: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
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; }
a {
margin-top: 0.5rem;
.status-row {
display: flex;
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;
padding: 0.6rem 1.4rem;
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; }
.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>
</head>
<body>
<div class="code">502</div>
<h1>Service Unavailable</h1>
<p>The server is temporarily unreachable. Please try again in a moment.</p>
<a href="/">Go home</a>
<header>
<a class="logo" href="/">libnovel</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>
</html>

View File

@@ -3,49 +3,163 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>503 — Maintenance</title>
<title>Under Maintenance — libnovel</title>
<meta http-equiv="refresh" content="30">
<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 ── */
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
/* ── Main ── */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
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; }
a {
margin-top: 0.5rem;
.status-row {
display: flex;
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;
padding: 0.6rem 1.4rem;
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; }
.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>
</head>
<body>
<div class="code">503</div>
<h1>Under Maintenance</h1>
<p>LibNovel is briefly offline for maintenance. We&rsquo;ll be back shortly.</p>
<a href="/">Try again</a>
<header>
<a class="logo" href="/">libnovel</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>
</html>

View File

@@ -3,49 +3,160 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>504 — Gateway Timeout</title>
<title>504 — Gateway Timeout — libnovel</title>
<meta http-equiv="refresh" content="20">
<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: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #09090b;
color: #a1a1aa;
font-family: ui-sans-serif, system-ui, sans-serif;
padding: 2rem;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.code {
font-size: clamp(4rem, 20vw, 8rem);
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #27272a;
color: #18181b;
line-height: 1;
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; }
a {
margin-top: 0.5rem;
.status-row {
display: flex;
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;
padding: 0.6rem 1.4rem;
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; }
.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>
</head>
<body>
<div class="code">504</div>
<h1>Gateway Timeout</h1>
<p>The request took too long to complete. Please refresh and try again.</p>
<a href="/">Go home</a>
<header>
<a class="logo" href="/">libnovel</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>
</html>

View File

@@ -15,7 +15,7 @@ x-infra-env: &infra-env
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# Meilisearch
MEILI_URL: "http://meilisearch:7700"
MEILI_URL: "${MEILI_URL:-http://meilisearch:7700}"
MEILI_API_KEY: "${MEILI_MASTER_KEY}"
# Valkey
VALKEY_ADDR: "valkey:6379"
@@ -126,6 +126,26 @@ services:
timeout: 5s
retries: 5
# ─── Redis (Asynq task queue — accessed locally by backend, remotely by homelab runner) ──
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--requirepass "${REDIS_PASSWORD}"
# No public port — backend reaches it via internal network.
# Homelab runner reaches it via Caddy TLS proxy on :6380 → redis:6379.
expose:
- "6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ─── Backend API ──────────────────────────────────────────────────────────────
backend:
image: kalekber/libnovel-backend:${GIT_TAG:-latest}
@@ -151,10 +171,12 @@ services:
condition: service_healthy
valkey:
condition: service_healthy
redis:
condition: service_healthy
# No public port — all traffic is routed via Caddy.
expose:
- "8080"
environment:
environment:
<<: *infra-env
BACKEND_HTTP_ADDR: ":8080"
LOG_LEVEL: "${LOG_LEVEL}"
@@ -164,10 +186,9 @@ services:
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
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}"
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
# Homelab runner connects to the same Redis via Caddy TLS proxy on :6380.
REDIS_ADDR: "redis:6379"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
healthcheck:
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
@@ -224,6 +245,7 @@ services:
# Kokoro-FastAPI TTS endpoint
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "runner"
@@ -268,6 +290,7 @@ services:
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
AUTH_SECRET: "${AUTH_SECRET}"
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
# Valkey
VALKEY_ADDR: "valkey:6379"
@@ -381,12 +404,10 @@ services:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
- "6380:6380" # Redis TCP proxy (TLS) for homelab → Asynq
- "6380:6380" # Redis TCP proxy (TLS) for homelab runner → Asynq
environment:
DOMAIN: "${DOMAIN}"
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:
- path: ./crowdsec/.crowdsec.env
required: false
@@ -400,15 +421,19 @@ services:
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
# 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:
image: containrrr/watchtower:latest
restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes:
- /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:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
HOME: "/root"
DOCKER_API_VERSION: "1.44"
volumes:
@@ -416,6 +441,7 @@ volumes:
pb_data:
meili_data:
valkey_data:
redis_data:
caddy_data:
caddy_config:
caddy_logs:

View File

@@ -35,11 +35,11 @@ client: Browser / iOS App {
caddy: Caddy :443 {
shape: rectangle
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 ─────────────────────────────────────────────────────────────
# 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 {
style.fill: "#fef3c7"
@@ -53,7 +53,7 @@ sk: SvelteKit UI :3000 {
catalogue_sk: Catalogue {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /api/catalogue-page\nGET /api/search"
label: "GET /api/catalogue-page (infinite scroll)\nGET /api/search"
}
book_sk: Book {
@@ -65,7 +65,7 @@ sk: SvelteKit UI :3000 {
scrape_sk: Scrape (admin) {
style.fill: "#fff7ed"
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 {
@@ -74,7 +74,7 @@ sk: SvelteKit UI :3000 {
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.stroke: "#86efac"
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 ───────────────────────────────────────────────────────────────
# Caddy proxies these paths directly — no SvelteKit auth layer
# Caddy proxies these paths directly — bypasses SvelteKit entirely.
be: Backend API :8080 {
style.fill: "#eef3ff"
health_be: Health {
health_be: Health / Version {
style.fill: "#f0fdf4"
style.stroke: "#86efac"
label: "GET /health\nGET /api/version"
@@ -126,7 +126,7 @@ be: Backend API :8080 {
catalogue_be: Catalogue {
style.fill: "#f0fdf4"
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 {
@@ -138,7 +138,13 @@ be: Backend API :8080 {
audio_be: Audio {
style.fill: "#f0fdf4"
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 {
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 {
shape: cylinder
label: "chapters · audio\navatars · browse"
label: "chapters · audio\navatars · catalogue (browse)"
}
ms: Meilisearch :7700 {
shape: cylinder
label: "index: books"
label: "index: books\nfilterable: status · genres\nsortable: rank · rating\n total_chapters · meta_updated"
}
vk: Valkey :6379 {
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
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles 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 -> storage.mn: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
caddy -> sk: "/* (catch-all)\n→ SvelteKit enforces auth"
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/* /audio/* /chapters/*\n(presigned MinIO GETs)"
# ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
sk.catalogue_sk -> be.catalogue_be: internal proxy
sk.book_sk -> be.book_be: internal proxy
sk.audio_sk -> be.audio_be: internal proxy
sk.presign_sk -> storage.vk: check cache
sk.presign_sk -> storage.mn: generate presign
sk.presign_user -> storage.mn: generate presign
sk.presign_sk -> be.presign_be: internal proxy
sk.presign_user -> be.presign_be: internal proxy
# ─── SvelteKit → Storage (direct) ────────────────────────────────────────────
@@ -192,10 +197,12 @@ sk.comments_sk -> storage.pb
# ─── 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.mn: cover presign
be.book_be -> storage.mn: chapter objects
be.book_be -> storage.pb: book metadata
be.audio_be -> storage.mn: audio presign
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 {
shape: cloud
style.fill: "#f0f4ff"
label: "novelfire.net\n(scrape source)"
}
kokoro: Kokoro-FastAPI TTS {
shape: cloud
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 {
shape: cloud
style.fill: "#f0f4ff"
label: "Let's Encrypt\n(ACME TLS-ALPN-01)"
}
browser: Browser / iOS App {
@@ -30,12 +39,12 @@ init: Init containers {
minio-init: minio-init {
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 {
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 {
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 {
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 {
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 {
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"
caddy: caddy {
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 {
shape: rectangle
label: "Backend API :8080\n(GoHTTP API server)"
}
runner: runner {
shape: rectangle
label: "Runner :9091\n(Go — background worker\nscraping + TTS jobs\n/metrics endpoint)"
label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors"
}
ui: ui {
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 {
style.fill: "#fef9ec"
style.fill: "#f5f5f5"
watchtower: Watchtower {
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.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.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 ────────────────────────────────────────────────────────────
# Routes sent directly to backend (no SvelteKit counterpart):
# /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.ui -> app.backend: "internal REST proxy\n(server-side only)"
app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments"
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)"
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.caddy -> storage.minio: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache"
app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking"
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)
app.runner -> kokoro: TTS generation\n(HTTP POST)
app.caddy -> letsencrypt: ACME certificate\n(TLS-ALPN-01)
homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown"
homelab.runner -> kokoro: "TTS generation\ntext → MP3"
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 ──────────────────────────────────────────────────────
ops.watchtower -> app.backend: watch (label-enabled)
ops.watchtower -> app.runner: watch (label-enabled)
ops.watchtower -> app.ui: watch (label-enabled)
# ─── Browser ──────────────────────────────────────────────────────────────────
# ─── Client ───────────────────────────────────────────────────────────────────
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

@@ -58,6 +58,14 @@ services:
VALKEY_ADDR: ""
GODEBUG: "preferIPv4=1"
# ── LibreTranslate (internal Docker network) ──────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis ─────────────────────────────────────────────────────
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
KOKORO_URL: "http://kokoro-fastapi:8880"
KOKORO_VOICE: "${KOKORO_VOICE}"
@@ -67,6 +75,7 @@ services:
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
@@ -221,7 +230,11 @@ services:
EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
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 ──────────────────────────────────────────────────────────────────
# Watches both homelab and prod containers.
@@ -276,6 +289,48 @@ services:
timeout: 5s
retries: 5
# ── Redis (Asynq task queue) ────────────────────────────────────────────────
# Dedicated Redis instance for Asynq job dispatch.
# The prod backend enqueues jobs via redis.libnovel.cc:6380 (Caddy TLS proxy →
# host:6379). The runner reads from this instance directly on the Docker network.
# Port is bound to 0.0.0.0:6379 so the Caddy layer4 proxy on prod can reach it.
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ── LibreTranslate ──────────────────────────────────────────────────────────
# Self-hosted machine translation. Runner connects via http://libretranslate:5000.
# Only English → configured target languages are loaded to save RAM.
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
expose:
- "5000"
volumes:
- libretranslate_data:/app/db
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5000/languages"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
# ── Valkey ──────────────────────────────────────────────────────────────────
# Used by GlitchTip for task queuing.
valkey:
@@ -439,19 +494,25 @@ services:
# ── Watchtower ──────────────────────────────────────────────────────────────
# Auto-updates runner image when CI pushes a new tag.
# 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:
image: containrrr/watchtower:latest
restart: unless-stopped
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd_homelab", "--"]
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
volumes:
- /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:
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
HOME: "/root"
DOCKER_API_VERSION: "1.44"
volumes:
postgres_data:
redis_data:
libretranslate_data:
valkey_data:
uptime_kuma_data:
gotify_data:

View File

@@ -11,31 +11,30 @@
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly)
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - Redis service for Asynq task queue (local to homelab, exposed to prod via Caddy TCP proxy)
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
services:
redis:
image: redis:7-alpine
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:
- 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_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
depends_on:
redis:
condition: service_healthy
- libretranslate
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
@@ -64,9 +63,14 @@ services:
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Asynq / Redis (local service) ───────────────────────────────────────
# The runner connects to the local Redis sidecar.
REDIS_ADDR: "redis:6379"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
# ── Runner tuning ───────────────────────────────────────────────────────
@@ -74,6 +78,7 @@ services:
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
@@ -89,4 +94,5 @@ services:
retries: 3
volumes:
redis_data:
libretranslate_models:
libretranslate_db:

View File

@@ -190,14 +190,15 @@ create "app_users" '{
{"name":"oauth_id", "type":"text"}
]}'
create "user_sessions" '{
create "user_sessions" '{
"name":"user_sessions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"session_id","type":"text","required":true},
{"name":"user_agent","type":"text"},
{"name":"ip", "type":"text"},
{"name":"created_at","type":"text"},
{"name":"last_seen", "type":"text"}
{"name":"user_id", "type":"text","required":true},
{"name":"session_id", "type":"text","required":true},
{"name":"user_agent", "type":"text"},
{"name":"ip", "type":"text"},
{"name":"device_fingerprint", "type":"text"},
{"name":"created_at", "type":"text"},
{"name":"last_seen", "type":"text"}
]}'
create "user_library" '{
@@ -245,6 +246,36 @@ create "comment_votes" '{
{"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"}
]}'
create "discovery_votes" '{
"name":"discovery_votes","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"slug", "type":"text","required":true},
{"name":"action", "type":"text","required":true}
]}'
create "book_ratings" '{
"name":"book_ratings","type":"base","fields":[
{"name":"session_id","type":"text", "required":true},
{"name":"user_id", "type":"text"},
{"name":"slug", "type":"text", "required":true},
{"name":"rating", "type":"number", "required":true}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"
@@ -258,5 +289,9 @@ add_field "app_users" "verification_token" "text"
add_field "app_users" "verification_token_exp" "text"
add_field "app_users" "oauth_provider" "text"
add_field "app_users" "oauth_id" "text"
add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
log "done"

3
ui/.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Vite
vite.config.js.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.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
ENV PORT=3000
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
CMD ["node", "build"]

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

@@ -0,0 +1,435 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Library",
"nav_catalogue": "Catalogue",
"nav_feed": "Feed",
"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_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light 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_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_changelog": "Changelog",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Push",
"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",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Following Feed",
"feed_subheading": "Books your followed users are reading",
"feed_empty_heading": "Nothing here yet",
"feed_empty_body": "Follow other readers to see what they're reading.",
"feed_not_logged_in": "Sign in to see your feed.",
"feed_reader_label": "reading",
"feed_chapters_label": "{n} chapters",
"feed_browse_cta": "Browse catalogue",
"feed_find_users_cta": "Discover readers"
}

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

@@ -0,0 +1,434 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
"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_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light 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_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Traduction",
"admin_nav_changelog": "Modifications",
"admin_nav_feedback": "Retours",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",
"admin_nav_uptime": "Disponibilité",
"admin_nav_push": "Notifications",
"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",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
"feed_empty_heading": "Rien encore",
"feed_empty_body": "Suivez d'autres lecteurs pour voir ce qu'ils lisent.",
"feed_not_logged_in": "Connectez-vous pour voir votre fil.",
"feed_reader_label": "lit",
"feed_chapters_label": "{n} chapitres",
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs"
}

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

@@ -0,0 +1,434 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
"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_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"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_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Terjemahan",
"admin_nav_changelog": "Perubahan",
"admin_nav_feedback": "Masukan",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notifikasi",
"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",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
"feed_empty_heading": "Belum ada apa-apa",
"feed_empty_body": "Ikuti pembaca lain untuk melihat apa yang mereka baca.",
"feed_not_logged_in": "Masuk untuk melihat umpan Anda.",
"feed_reader_label": "membaca",
"feed_chapters_label": "{n} bab",
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca"
}

434
ui/messages/pt.json Normal file
View File

@@ -0,0 +1,434 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
"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_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",
"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_nav_scrape": "Scrape",
"admin_nav_audio": "Áudio",
"admin_nav_translation": "Tradução",
"admin_nav_changelog": "Alterações",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notificações",
"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",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
"feed_empty_heading": "Nada aqui ainda",
"feed_empty_body": "Siga outros leitores para ver o que estão lendo.",
"feed_not_logged_in": "Faça login para ver seu feed.",
"feed_reader_label": "lendo",
"feed_chapters_label": "{n} capítulos",
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores"
}

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

@@ -0,0 +1,434 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
"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_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light 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_nav_scrape": "Скрейпинг",
"admin_nav_audio": "Аудио",
"admin_nav_translation": "Перевод",
"admin_nav_changelog": "Изменения",
"admin_nav_feedback": "Отзывы",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",
"admin_nav_uptime": "Мониторинг",
"admin_nav_push": "Уведомления",
"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": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
"feed_empty_heading": "Пока ничего нет",
"feed_empty_body": "Подпишитесь на других читателей, чтобы видеть, что они читают.",
"feed_not_logged_in": "Войдите, чтобы видеть свою ленту.",
"feed_reader_label": "читает",
"feed_chapters_label": "{n} глав",
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей"
}

222
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^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-trace-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
@@ -1719,6 +1720,49 @@
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
@@ -1780,6 +1824,43 @@
"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": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -4135,6 +4216,12 @@
"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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz",
@@ -4867,6 +4954,15 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -5405,6 +5501,12 @@
"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": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -5590,6 +5692,28 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -5597,6 +5721,15 @@
"dev": true,
"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": {
"version": "2.0.0",
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -5966,6 +6113,15 @@
"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": {
"version": "3.0.0",
"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"
}
},
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6101,6 +6263,15 @@
"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": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -6988,6 +7159,17 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
@@ -7201,6 +7383,21 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -7231,6 +7428,25 @@
"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": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@@ -7330,6 +7546,12 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",

View File

@@ -8,6 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && node -e \"const fs=require('fs'),f='./src/lib/paraglide/messages.js',c=fs.readFileSync(f,'utf8').split('\\n').filter(l=>!l.includes('export * as m')&&!l.includes('enabling auto-import')).join('\\n');fs.writeFileSync(f,c)\"",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
@@ -30,6 +31,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^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-trace-otlp-http": "^0.214.0",
"@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", "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,93 @@
--color-surface-3: #3f3f46; /* zinc-700 */
--color-muted: #a1a1aa; /* zinc-400 */
--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 */
}
/* ── Light amber theme ────────────────────────────────────────────────── */
[data-theme="light"] {
--color-brand: #d97706; /* amber-600 */
--color-brand-dim: #b45309; /* amber-700 */
--color-surface: #ffffff;
--color-surface-2: #f4f4f5; /* zinc-100 */
--color-surface-3: #e4e4e7; /* zinc-200 */
--color-muted: #71717a; /* zinc-500 */
--color-text: #18181b; /* zinc-900 */
--color-border: #d4d4d8; /* zinc-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light slate theme ────────────────────────────────────────────────── */
[data-theme="light-slate"] {
--color-brand: #4f46e5; /* indigo-600 */
--color-brand-dim: #4338ca; /* indigo-700 */
--color-surface: #f8fafc; /* slate-50 */
--color-surface-2: #f1f5f9; /* slate-100 */
--color-surface-3: #e2e8f0; /* slate-200 */
--color-muted: #64748b; /* slate-500 */
--color-text: #0f172a; /* slate-900 */
--color-border: #cbd5e1; /* slate-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
/* ── Light rose theme ─────────────────────────────────────────────────── */
[data-theme="light-rose"] {
--color-brand: #e11d48; /* rose-600 */
--color-brand-dim: #be123c; /* rose-700 */
--color-surface: #fff1f2; /* rose-50 */
--color-surface-2: #ffe4e6; /* rose-100 */
--color-surface-3: #fecdd3; /* rose-200 */
--color-muted: #9f1239; /* rose-800 at 60% */
--color-text: #0f0a0b; /* near black */
--color-border: #fda4af; /* rose-300 */
--color-danger: #dc2626; /* red-600 */
--color-success: #16a34a; /* green-600 */
}
html {
@@ -15,18 +102,25 @@ html {
color: var(--color-text);
}
/* ── Reading typography custom properties ──────────────────────────── */
:root {
--reading-font: system-ui, -apple-system, sans-serif;
--reading-size: 1.05rem;
}
/* ── Chapter prose ─────────────────────────────────────────────────── */
.prose-chapter {
max-width: 72ch;
line-height: 1.85;
font-size: 1.05rem;
color: #d4d4d8; /* zinc-300 */
font-family: var(--reading-font);
font-size: var(--reading-size);
color: var(--color-muted);
}
.prose-chapter h1,
.prose-chapter h2,
.prose-chapter h3 {
color: #f4f4f5;
color: var(--color-text);
font-weight: 700;
margin-top: 1.5em;
margin-bottom: 0.5em;
@@ -41,18 +135,27 @@ html {
}
.prose-chapter em {
color: #a1a1aa;
color: var(--color-muted);
}
.prose-chapter strong {
color: #f4f4f5;
color: var(--color-text);
}
.prose-chapter hr {
border-color: #3f3f46;
border-color: var(--color-border);
margin: 2em 0;
}
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
.scrollbar-none {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / Edge legacy */
}
.scrollbar-none::-webkit-scrollbar {
display: none; /* Chrome / Safari / WebKit */
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }
@@ -62,4 +165,3 @@ html {
.animate-progress-bar {
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 {
sessionId: string;
user: { id: string; username: string; role: string; authSessionId: string } | null;
isPro: boolean;
}
interface PageData {
user?: { id: string; username: string; role: string; authSessionId: string } | null;
isPro?: boolean;
}
// interface PageState {}
// interface Platform {}

View File

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

209
ui/src/error.html Normal file
View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%sveltekit.status% — 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;
}
/* Inline SVG book illustration */
.illustration {
width: 96px;
height: 96px;
margin-bottom: 2rem;
opacity: 0.9;
}
.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: #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;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.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; }
.btn-secondary {
background: transparent;
color: #a1a1aa;
border: 1px solid #27272a;
}
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
.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;
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<!-- Book with broken spine SVG -->
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Book cover -->
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Spine -->
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Pages edge -->
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
<!-- Crack / broken lines -->
<path d="M22 38 L38 34 L34 48 L50 44" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Text lines (faded) -->
<rect x="22" y="24" width="28" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="30" width="22" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="56" width="28" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="62" width="18" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="68" width="24" height="3" rx="1.5" fill="#3f3f46"/>
<!-- Exclamation dot -->
<circle cx="72" cy="22" r="10" fill="#18181b" stroke="#f59e0b" stroke-width="1.5"/>
<rect x="71" y="16" width="2" height="8" rx="1" fill="#f59e0b"/>
<rect x="71" y="26" width="2" height="2" rx="1" fill="#f59e0b"/>
</svg>
<div class="watermark">%sveltekit.status%</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Something went wrong</span>
</div>
<h1>The page couldn't load</h1>
<p>An unexpected error occurred. We're looking into it — try again in a moment.</p>
<div class="actions">
<a class="btn" href="/">Go home</a>
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
</div>
<p class="refresh-note">Auto-refreshing 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>
</html>

View File

@@ -1,11 +1,12 @@
import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { handleErrorWithSentry } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
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 { NodeSDK } from '@opentelemetry/sdk-node';
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 { resourceFromAttributes } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { paraglideMiddleware } from '$lib/paraglide/server';
// ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
// 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 ─────────────────────────────────────────────────────────────────────
export const handle: Handle = async ({ event, resolve }) => {
function getTextDirection(locale: string): string {
// All supported locales (en, ru, id, pt, 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
// balancer / Docker health-check can drain existing connections.
if (shuttingDown) {
@@ -194,6 +210,20 @@ export const handle: Handle = async ({ event, resolve }) => {
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);
};
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

@@ -75,6 +75,13 @@ class AudioStore {
*/
seekRequest = $state<number | null>(null);
// ── Sleep timer ──────────────────────────────────────────────────────────
/** Epoch ms when sleep timer should fire. 0 = off. */
sleepUntil = $state(0);
/** When true, pause after the current chapter ends instead of navigating. */
sleepAfterChapter = $state(false);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends

View File

@@ -52,6 +52,7 @@
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
interface Props {
slug: string;
@@ -66,6 +67,8 @@
chapters?: { number: number; title: string }[];
/** List of available voices from the backend. */
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
}
let {
@@ -76,7 +79,8 @@
cover = '',
nextChapter = null,
chapters = [],
voices = []
voices = [],
onProRequired = undefined
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -562,6 +566,15 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (res.status === 402) {
// Free daily limit reached — surface upgrade CTA
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
return;
}
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status === 200) {
@@ -668,6 +681,53 @@
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── Sleep timer ────────────────────────────────────────────────────────────
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
let _tick = $state(0);
$effect(() => {
if (!audioStore.sleepUntil) return;
const id = setInterval(() => { _tick++; }, 1000);
return () => clearInterval(id);
});
let sleepRemainingSec = $derived.by(() => {
_tick; // subscribe to tick updates
if (!audioStore.sleepUntil) return 0;
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
});
function cycleSleepTimer() {
// Currently: no timer active at all
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
return;
}
// Currently: end-of-chapter mode — move to 15m
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
return;
}
// Currently: timed mode — cycle to next or turn off
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
function formatSleepRemaining(secs: number): string {
if (secs <= 0) return '';
const m = Math.floor(secs / 60);
const s = secs % 60;
if (m > 0) return `${m}m`;
return `${s}s`;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -675,7 +735,7 @@
<!-- ── 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-zinc-800 transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-amber-400/10')}
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)}
@@ -684,25 +744,25 @@
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v.id}
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<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-amber-400 font-medium' : 'text-zinc-300')}>
<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-zinc-600 text-xs font-mono">{v.id}</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-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
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 ? 'Stop sample' : 'Play sample'}
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}
@@ -718,13 +778,13 @@
</div>
{/snippet}
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
<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 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"/>
</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>
<!-- Voice selector button -->
@@ -733,8 +793,8 @@
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
title="Change voice"
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title={m.reader_change_voice()}
>
<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"/>
@@ -749,15 +809,15 @@
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
{#if showVoicePanel && voices.length > 0}
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
<Button
variant="ghost"
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; }}
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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@@ -767,8 +827,8 @@
<div class="max-h-64 overflow-y-auto">
<!-- Kokoro (GPU) section -->
{#if kokoroVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
</div>
{#each kokoroVoices as v (v.id)}
{@render voiceRow(v)}
@@ -777,26 +837,26 @@
<!-- Pocket TTS (CPU) section -->
{#if pocketVoices.length > 0}
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50 {kokoroVoices.length > 0 ? 'border-t border-zinc-700' : ''}">
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Pocket TTS (CPU)</span>
<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 class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500">
New voice applies on next "Play narration".
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">
{m.reader_voice_applies_next()}
{#if voices.length > 0}
<a
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) => {
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}}
>Generate missing samples</a>
>{m.reader_generate_samples()}</a>
{/if}
</p>
</div>
@@ -808,14 +868,14 @@
{#if audioStore.status === 'idle' || 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}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</Button>
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{m.reader_play_narration()}
</Button>
{:else if audioStore.status === 'loading'}
<Button variant="default" size="sm" disabled>
@@ -823,37 +883,37 @@
<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>
</svg>
Loading…
{m.player_loading()}
</Button>
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
<p class="text-xs text-zinc-400">Generating narration</p>
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
<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}%"
></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>
{:else if audioStore.status === 'ready'}
<!-- 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 gap-2 text-xs text-zinc-400">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
{:else}
<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"/>
</svg>
<span>Paused — controls below</span>
{/if}
<span class="tabular-nums text-zinc-500">
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
{#if audioStore.isPlaying}
<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"/>
</svg>
<span>{m.reader_playing()}</span>
{:else}
<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"/>
</svg>
<span>{m.reader_paused()}</span>
{/if}
<span class="tabular-nums text-(--color-muted) opacity-60">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
@@ -863,40 +923,64 @@
<Button
variant="ghost"
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')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
>
<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"/>
</svg>
Auto
</Button>
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)}
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
aria-pressed={audioStore.autoNext}
>
<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"/>
</svg>
{m.reader_auto_next()}
</Button>
{/if}
<!-- Sleep timer -->
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep
{/if}
</Button>
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-zinc-500">
<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>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 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"/>
</svg>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
{/if}
{#if audioStore.nextStatus === 'prefetching'}
<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">
<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>
</svg>
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-(--color-muted) flex items-center gap-1">
<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"/>
</svg>
{m.reader_ch_ready({ n: String(nextChapter) })}
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
{/if}
</div>
{/if}
{/if}
@@ -904,11 +988,11 @@
{:else if audioStore.active}
<!-- ── A different chapter is currently playing ── -->
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-zinc-400">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
<p class="text-xs text-(--color-muted)">
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
Load this chapter
{m.reader_load_this_chapter()}
</Button>
</div>
@@ -918,7 +1002,7 @@
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
{m.reader_play_narration()}
</Button>
{/if}
</div>

View File

@@ -93,14 +93,14 @@
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<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
bind:this={imgEl}
alt="Crop preview"
style="display:block; max-width:100%; max-height:100%;"
/>
</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
</p>
</div>

View File

@@ -3,12 +3,15 @@
import { Textarea } from '$lib/components/ui/textarea';
import { cn } from '$lib/utils';
import type { BookComment } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
let {
slug,
chapter = 0,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
chapter?: number; // 0 = book-level, N = chapter N
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
@@ -46,7 +49,7 @@
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}${chapter > 0 ? `&chapter=${chapter}` : ''}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
@@ -84,7 +87,7 @@
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
body: JSON.stringify({ body: text, ...(chapter > 0 ? { chapter } : {}) })
});
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
if (!res.ok) {
@@ -243,28 +246,28 @@
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200">
Comments
<h2 class="text-base font-semibold text-(--color-text)">
{m.comments_heading()}
{#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}
</h2>
<!-- Sort tabs -->
{#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
variant="ghost"
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')}
>Top</Button>
>{m.comments_top()}</Button>
<Button
variant="ghost"
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')}
>New</Button>
>{m.comments_new()}</Button>
</div>
{/if}
</div>
@@ -275,16 +278,16 @@
<div class="flex flex-col gap-2">
<Textarea
bind:value={newBody}
placeholder="Write a comment…"
placeholder={m.comments_placeholder()}
rows={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
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
<span class="text-xs text-(--color-danger)">{postError}</span>
{/if}
<Button
variant="default"
@@ -292,15 +295,15 @@
disabled={posting || !newBody.trim() || charOver}
onclick={postComment}
>
{posting ? 'Posting…' : 'Post'}
{posting ? m.comments_posting() : m.comments_submit()}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
to leave a comment.
<p class="text-sm text-(--color-muted)">
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
{m.comments_login_suffix()}
</p>
{/if}
</div>
@@ -309,17 +312,17 @@
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
</div>
{/each}
</div>
{: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}
<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}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
@@ -328,39 +331,39 @@
{@const deleting = deletingIds.has(comment.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' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#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" />
{: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>
<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 -->
<div class="flex items-center gap-2 flex-wrap">
{#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" />
{: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}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
{: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 -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
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')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title="Upvote"
>
<Button
variant="ghost"
size="sm"
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}
onclick={() => vote(comment.id, 'up')}
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">
<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>
@@ -368,14 +371,14 @@
</Button>
<!-- Downvote -->
<Button
variant="ghost"
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')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title="Downvote"
>
<Button
variant="ghost"
size="sm"
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}
onclick={() => vote(comment.id, 'down')}
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">
<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>
@@ -384,11 +387,11 @@
<!-- Reply button -->
{#if isLoggedIn}
<Button
variant="ghost"
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')}
onclick={() => {
<Button
variant="ghost"
size="sm"
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={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
@@ -403,57 +406,57 @@
<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"/>
</svg>
Reply
{m.comments_reply()}
</Button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<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"/>
</svg>
Delete
{m.comments_delete()}
</Button>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<Textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-zinc-400 hover:text-zinc-200"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button>
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
<Textarea
bind:value={replyBody}
placeholder={m.comments_placeholder()}
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-(--color-danger)">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-(--color-muted) hover:text-(--color-text)"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>{m.common_cancel()}</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? 'Posting…' : 'Reply'}
{replyPosting ? m.comments_posting() : m.comments_reply()}
</Button>
</div>
</div>
@@ -462,59 +465,59 @@
<!-- Replies -->
{#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">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<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)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.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' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#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" />
{: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>
<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 -->
<div class="flex items-center gap-2 flex-wrap">
{#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" />
{: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}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
</div>
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
{: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 -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply body -->
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
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')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote"
>
<Button
variant="ghost"
size="sm"
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}
onclick={() => vote(reply.id, 'up', comment.id)}
title={m.comments_vote_up()}
>
<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"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
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')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote"
>
<Button
variant="ghost"
size="sm"
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}
onclick={() => vote(reply.id, 'down', comment.id)}
title={m.comments_vote_down()}
>
<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"/>
</svg>
@@ -522,19 +525,19 @@
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<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"/>
</svg>
Delete
</Button>
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<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"/>
</svg>
{m.comments_delete()}
</Button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
interface Props {
rating: number; // current user rating 05 (0 = unrated)
avg?: number; // average rating
count?: number; // total ratings
readonly?: boolean; // display-only mode
size?: 'sm' | 'md';
onrate?: (r: number) => void;
}
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
let hovered = $state(0);
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
const display = $derived(hovered || rating || 0);
</script>
<div class="flex items-center gap-1">
<div class="flex items-center gap-0.5">
{#each [1,2,3,4,5] as star}
<button
type="button"
disabled={readonly}
onmouseenter={() => { if (!readonly) hovered = star; }}
onmouseleave={() => { if (!readonly) hovered = 0; }}
onclick={() => { if (!readonly) onrate?.(star); }}
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
>
<svg
class="{starSize} transition-colors"
fill={star <= display ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="1.5"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
</button>
{/each}
</div>
{#if avg && count}
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
{:else if avg}
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
{/if}
</div>
<style>
button[disabled] { pointer-events: none; }
svg { color: #f59e0b; }
</style>

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';
const variants: Record<Variant, string> = {
default: 'border-transparent bg-amber-400 text-zinc-900',
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
outline: 'border-zinc-600 text-zinc-300',
destructive: 'border-transparent bg-red-500/20 text-red-400',
default: 'border-transparent bg-(--color-brand) text-(--color-surface)',
secondary: 'border-transparent bg-(--color-surface-3) text-(--color-text)',
outline: 'border-(--color-border) text-(--color-muted)',
destructive: 'border-transparent bg-(--color-danger)/20 text-(--color-danger)',
};
</script>

View File

@@ -28,15 +28,15 @@
}: Props = $props();
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> = {
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
default: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)',
secondary: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-border)',
outline: 'border border-(--color-border) bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
ghost: 'bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
destructive: 'bg-(--color-danger)/20 text-(--color-danger) hover:bg-(--color-danger)/30',
link: 'text-(--color-brand) underline-offset-4 hover:underline bg-transparent p-0 h-auto',
};
const sizes: Record<Size, string> = {

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</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?.()}
</div>

View File

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

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</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?.()}
</h3>

View File

@@ -36,7 +36,7 @@
aria-modal="true"
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?.()}
</div>
</div>

View File

@@ -10,6 +10,6 @@
let { class: className = '', children }: Props = $props();
</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?.()}
</h2>

View File

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

View File

@@ -30,8 +30,8 @@
{rows}
{disabled}
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',
'focus:outline-none focus:border-amber-400',
'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-(--color-brand)',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}

View File

@@ -0,0 +1,3 @@
# ignore everything because the directory is auto-generated by inlang paraglide-js
# for more info visit https://inlang.com/m/gerre34r/paraglide-js
*

View File

@@ -0,0 +1,147 @@
# Paraglide JS Compiled Output
> Auto-generated i18n message functions. Import `messages.js` to use translated strings.
Compiled from: `/Users/kalekber/code/libnovel-v2/ui/project.inlang`
## What is this folder?
This folder contains compiled [Paraglide JS](https://github.com/opral/paraglide-js) output. Paraglide JS compiles your translation messages into tree-shakeable JavaScript functions.
## At a glance
Purpose:
- This folder stores compiled i18n message functions.
- Source translations live outside this folder in your inlang project.
Safe to import:
- `messages.js` — all message functions
- `runtime.js` — locale utilities
- `server.js` — server-side middleware
Do not edit:
- All files in this folder are auto-generated.
- Changes will be overwritten on next compilation.
```
paraglide/
├── messages.js # Message exports (import this)
├── messages/ # Individual message functions
├── runtime.js # Locale detection & configuration
├── registry.js # Formatting utilities (plural, number, datetime)
├── server.js # Server-side middleware
└── .gitignore # Marks folder as generated
```
## Usage
```js
import * as m from "./paraglide/messages.js";
// Messages are functions that return localized strings
m.hello_world(); // "Hello, World!" (in current locale)
m.greeting({ name: "Sam" }); // "Hello, Sam!"
// Override locale per-call
m.hello_world({}, { locale: "de" }); // "Hallo, Welt!"
m.greeting({ name: "Sam" }, { locale: "de" }); // "Hallo, Sam!"
```
## Runtime API
```js
import { getLocale, getTextDirection, setLocale, locales, baseLocale } from "./paraglide/runtime.js";
getLocale(); // Current locale, e.g., "en"
getTextDirection(); // "ltr" | "rtl" for current locale
setLocale("de"); // Set locale
locales; // Available locales, e.g., ["en", "de", "fr"]
baseLocale; // Default locale, e.g., "en"
```
## Strategy
The strategy determines how the current locale is detected and persisted:
- **Cookie**: Stores locale preference in a cookie.
- **URL**: Derives locale from URL patterns (e.g., `/en/about`, `en.example.com`).
- **GlobalVariable**: Uses a global variable (client-side only).
- **BaseLocale**: Always returns the base locale.
Strategies can be combined. The order defines precedence:
```js
await compile({
project: "./project.inlang",
outdir: "./src/paraglide",
strategy: ["url", "cookie", "baseLocale"],
});
```
See the [strategy documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/strategy) for details.
## Markup (Rich Text)
Messages can contain markup tags for bold, links, and other inline elements. Translators control where tags appear; developers control how they render.
### Message syntax
```json
{
"cta": "{#link to=|/docs|}Read the docs{/link}",
"bold_text": "This is {#bold}important{/bold}"
}
```
- `{#tagName}` opens a tag, `{/tagName}` closes it.
- Options: `to=|/docs|` (accessed via `options.to`).
- Attributes: `@track` (boolean, accessed via `attributes.track`).
This is the default inlang message syntax. Paraglide's message format is plugin-based — you can use [ICU MessageFormat 1](https://inlang.com/m/p7c8m1d2/plugin-inlang-icu-messageformat-1), [i18next](https://inlang.com/m/3i8bor92/plugin-inlang-i18next), or other [plugins](https://inlang.com/c/plugins) instead.
### Rendering markup
Calling `m.cta()` returns **plain text** (markup stripped). To render markup, use the framework adapter or the low-level `parts()` API:
```js
const parts = m.cta.parts({});
// [
// { type: "markup-start", name: "link", options: { to: "/docs" }, attributes: {} },
// { type: "text", value: "Read the docs" },
// { type: "markup-end", name: "link" }
// ]
```
Framework adapters provide a `<ParaglideMessage>` component that accepts markup renderers:
- `@inlang/paraglide-js-react`
- `@inlang/paraglide-js-vue`
- `@inlang/paraglide-js-svelte`
- `@inlang/paraglide-js-solid`
```jsx
import { ParaglideMessage } from "@inlang/paraglide-js-react"; // or -vue, -svelte, -solid
<ParaglideMessage
message={m.cta}
inputs={{}}
markup={{
link: ({ children, options }) => <a href={options.to}>{children}</a>,
}}
/>
```
See the [markup documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/markup) for details.
## Key concepts
- **Tree-shakeable**: Each message is a function, enabling [up to 70% smaller i18n bundle sizes](https://inlang.com/m/gerre34r/library-inlang-paraglideJs/benchmark) than traditional i18n libraries.
- **Typesafe**: Full TypeScript/JSDoc support with autocomplete.
- **Variants**: Messages can have variants for pluralization, gender, etc.
- **Fallbacks**: Missing translations fall back to the base locale.
## Links
- [Paraglide JS Documentation](https://inlang.com/m/gerre34r/library-inlang-paraglideJs)
- [Source Repository](https://github.com/opral/paraglide-js)

View File

@@ -0,0 +1,2 @@
/* eslint-disable */
export * from './messages/_index.js'

View File

@@ -0,0 +1,397 @@
/* eslint-disable */
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
export * from './nav_library.js'
export * from './nav_catalogue.js'
export * from './nav_feed.js'
export * from './nav_feedback.js'
export * from './nav_admin.js'
export * from './nav_profile.js'
export * from './nav_sign_in.js'
export * from './nav_sign_out.js'
export * from './nav_toggle_menu.js'
export * from './nav_admin_panel.js'
export * from './footer_library.js'
export * from './footer_catalogue.js'
export * from './footer_feedback.js'
export * from './footer_disclaimer.js'
export * from './footer_privacy.js'
export * from './footer_dmca.js'
export * from './footer_copyright.js'
export * from './footer_dev.js'
export * from './home_title.js'
export * from './home_stat_books.js'
export * from './home_stat_chapters.js'
export * from './home_stat_in_progress.js'
export * from './home_continue_reading.js'
export * from './home_view_all.js'
export * from './home_recently_updated.js'
export * from './home_from_following.js'
export * from './home_empty_title.js'
export * from './home_empty_body.js'
export * from './home_discover_novels.js'
export * from './home_via_reader.js'
export * from './home_chapter_badge.js'
export * from './player_generating.js'
export * from './player_loading.js'
export * from './player_chapters.js'
export * from './player_chapter_n.js'
export * from './player_toggle_chapter_list.js'
export * from './player_chapter_list_label.js'
export * from './player_close_chapter_list.js'
export * from './player_rewind_15.js'
export * from './player_skip_30.js'
export * from './player_back_15.js'
export * from './player_forward_30.js'
export * from './player_play.js'
export * from './player_pause.js'
export * from './player_speed_label.js'
export * from './player_seek_label.js'
export * from './player_change_speed.js'
export * from './player_auto_next_on.js'
export * from './player_auto_next_off.js'
export * from './player_auto_next_ready.js'
export * from './player_auto_next_preparing.js'
export * from './player_auto_next_aria.js'
export * from './player_go_to_chapter.js'
export * from './player_close.js'
export * from './login_page_title.js'
export * from './login_heading.js'
export * from './login_subheading.js'
export * from './login_continue_google.js'
export * from './login_continue_github.js'
export * from './login_terms_notice.js'
export * from './login_error_oauth_state.js'
export * from './login_error_oauth_failed.js'
export * from './login_error_oauth_no_email.js'
export * from './books_page_title.js'
export * from './books_heading.js'
export * from './books_empty_title.js'
export * from './books_empty_body.js'
export * from './books_browse_catalogue.js'
export * from './books_chapter_count.js'
export * from './books_last_read.js'
export * from './books_reading_progress.js'
export * from './books_remove.js'
export * from './catalogue_page_title.js'
export * from './catalogue_heading.js'
export * from './catalogue_search_placeholder.js'
export * from './catalogue_filter_genre.js'
export * from './catalogue_filter_status.js'
export * from './catalogue_filter_sort.js'
export * from './catalogue_sort_popular.js'
export * from './catalogue_sort_new.js'
export * from './catalogue_sort_top_rated.js'
export * from './catalogue_sort_rank.js'
export * from './catalogue_status_all.js'
export * from './catalogue_status_ongoing.js'
export * from './catalogue_status_completed.js'
export * from './catalogue_genre_all.js'
export * from './catalogue_clear_filters.js'
export * from './catalogue_reset.js'
export * from './catalogue_no_results.js'
export * from './catalogue_loading.js'
export * from './catalogue_load_more.js'
export * from './catalogue_results_count.js'
export * from './book_detail_page_title.js'
export * from './book_detail_signin_to_save.js'
export * from './book_detail_add_to_library.js'
export * from './book_detail_remove_from_library.js'
export * from './book_detail_read_now.js'
export * from './book_detail_continue_reading.js'
export * from './book_detail_start_reading.js'
export * from './book_detail_chapters.js'
export * from './book_detail_status.js'
export * from './book_detail_author.js'
export * from './book_detail_genres.js'
export * from './book_detail_description.js'
export * from './book_detail_source.js'
export * from './book_detail_rescrape.js'
export * from './book_detail_scraping.js'
export * from './book_detail_in_library.js'
export * from './chapters_page_title.js'
export * from './chapters_heading.js'
export * from './chapters_back_to_book.js'
export * from './chapters_reading_now.js'
export * from './chapters_empty.js'
export * from './reader_page_title.js'
export * from './reader_play_narration.js'
export * from './reader_generating_audio.js'
export * from './reader_signin_for_audio.js'
export * from './reader_signin_audio_desc.js'
export * from './reader_audio_error.js'
export * from './reader_prev_chapter.js'
export * from './reader_next_chapter.js'
export * from './reader_back_to_chapters.js'
export * from './reader_chapter_n.js'
export * from './reader_change_voice.js'
export * from './reader_voice_panel_title.js'
export * from './reader_voice_kokoro.js'
export * from './reader_voice_pocket.js'
export * from './reader_voice_play_sample.js'
export * from './reader_voice_stop_sample.js'
export * from './reader_voice_selected.js'
export * from './reader_close_voice_panel.js'
export * from './reader_auto_next.js'
export * from './reader_speed.js'
export * from './reader_preview_notice.js'
export * from './profile_page_title.js'
export * from './profile_heading.js'
export * from './profile_avatar_label.js'
export * from './profile_change_avatar.js'
export * from './profile_username.js'
export * from './profile_email.js'
export * from './profile_change_password.js'
export * from './profile_current_password.js'
export * from './profile_new_password.js'
export * from './profile_confirm_password.js'
export * from './profile_save_password.js'
export * from './profile_appearance_heading.js'
export * from './profile_theme_label.js'
export * from './profile_theme_amber.js'
export * from './profile_theme_slate.js'
export * from './profile_theme_rose.js'
export * from './profile_theme_light.js'
export * from './profile_theme_light_slate.js'
export * from './profile_theme_light_rose.js'
export * from './profile_reading_heading.js'
export * from './profile_voice_label.js'
export * from './profile_speed_label.js'
export * from './profile_auto_next_label.js'
export * from './profile_save_settings.js'
export * from './profile_settings_saved.js'
export * from './profile_settings_error.js'
export * from './profile_password_saved.js'
export * from './profile_password_error.js'
export * from './profile_sessions_heading.js'
export * from './profile_sign_out_all.js'
export * from './profile_joined.js'
export * from './user_page_title.js'
export * from './user_library_heading.js'
export * from './user_follow.js'
export * from './user_unfollow.js'
export * from './user_followers.js'
export * from './user_following.js'
export * from './user_library_empty.js'
export * from './error_not_found_title.js'
export * from './error_not_found_body.js'
export * from './error_generic_title.js'
export * from './error_go_home.js'
export * from './error_status.js'
export * from './admin_scrape_page_title.js'
export * from './admin_scrape_heading.js'
export * from './admin_scrape_catalogue.js'
export * from './admin_scrape_book.js'
export * from './admin_scrape_url_placeholder.js'
export * from './admin_scrape_range.js'
export * from './admin_scrape_from.js'
export * from './admin_scrape_to.js'
export * from './admin_scrape_submit.js'
export * from './admin_scrape_cancel.js'
export * from './admin_scrape_status_pending.js'
export * from './admin_scrape_status_running.js'
export * from './admin_scrape_status_done.js'
export * from './admin_scrape_status_failed.js'
export * from './admin_scrape_status_cancelled.js'
export * from './admin_tasks_heading.js'
export * from './admin_tasks_empty.js'
export * from './admin_audio_page_title.js'
export * from './admin_audio_heading.js'
export * from './admin_audio_empty.js'
export * from './admin_changelog_page_title.js'
export * from './admin_changelog_heading.js'
export * from './comments_heading.js'
export * from './comments_empty.js'
export * from './comments_placeholder.js'
export * from './comments_submit.js'
export * from './comments_login_prompt.js'
export * from './comments_vote_up.js'
export * from './comments_vote_down.js'
export * from './comments_delete.js'
export * from './comments_reply.js'
export * from './comments_show_replies.js'
export * from './comments_hide_replies.js'
export * from './comments_edited.js'
export * from './comments_deleted.js'
export * from './disclaimer_page_title.js'
export * from './privacy_page_title.js'
export * from './dmca_page_title.js'
export * from './terms_page_title.js'
export * from './common_loading.js'
export * from './common_error.js'
export * from './common_save.js'
export * from './common_cancel.js'
export * from './common_close.js'
export * from './common_search.js'
export * from './common_back.js'
export * from './common_next.js'
export * from './common_previous.js'
export * from './common_yes.js'
export * from './common_no.js'
export * from './common_on.js'
export * from './common_off.js'
export * from './locale_switcher_label.js'
export * from './books_empty_library.js'
export * from './books_empty_discover.js'
export * from './books_empty_discover_link.js'
export * from './books_empty_discover_suffix.js'
export * from './books_count.js'
export * from './catalogue_sort_updated.js'
export * from './catalogue_search_button.js'
export * from './catalogue_refresh.js'
export * from './catalogue_refreshing.js'
export * from './catalogue_refresh_mobile.js'
export * from './catalogue_all_loaded.js'
export * from './catalogue_scroll_top.js'
export * from './catalogue_view_grid.js'
export * from './catalogue_view_list.js'
export * from './catalogue_browse_source.js'
export * from './catalogue_search_results.js'
export * from './catalogue_search_local_count.js'
export * from './catalogue_rank_ranked.js'
export * from './catalogue_rank_no_data.js'
export * from './catalogue_rank_no_data_body.js'
export * from './catalogue_rank_run_scrape_admin.js'
export * from './catalogue_rank_run_scrape_user.js'
export * from './catalogue_scrape_queued_flash.js'
export * from './catalogue_scrape_busy_flash.js'
export * from './catalogue_scrape_error_flash.js'
export * from './catalogue_filters_label.js'
export * from './catalogue_apply.js'
export * from './catalogue_filter_rank_note.js'
export * from './catalogue_no_results_search.js'
export * from './catalogue_no_results_try.js'
export * from './catalogue_no_results_filters.js'
export * from './catalogue_scrape_queued_badge.js'
export * from './catalogue_scrape_busy_badge.js'
export * from './catalogue_scrape_busy_list.js'
export * from './catalogue_scrape_forbidden_badge.js'
export * from './catalogue_scrape_novel_button.js'
export * from './catalogue_scraping_novel.js'
export * from './book_detail_not_in_library.js'
export * from './book_detail_continue_ch.js'
export * from './book_detail_start_ch1.js'
export * from './book_detail_preview_ch1.js'
export * from './book_detail_reading_ch.js'
export * from './book_detail_n_chapters.js'
export * from './book_detail_rescraping.js'
export * from './book_detail_from_chapter.js'
export * from './book_detail_to_chapter.js'
export * from './book_detail_range_queuing.js'
export * from './book_detail_scrape_range.js'
export * from './book_detail_admin.js'
export * from './book_detail_scraping_progress.js'
export * from './book_detail_scraping_home.js'
export * from './book_detail_rescrape_book.js'
export * from './book_detail_less.js'
export * from './book_detail_more.js'
export * from './chapters_search_placeholder.js'
export * from './chapters_jump_to.js'
export * from './chapters_no_match.js'
export * from './chapters_none_available.js'
export * from './chapters_reading_indicator.js'
export * from './chapters_result_count.js'
export * from './reader_fetching_chapter.js'
export * from './reader_words.js'
export * from './reader_preview_audio_notice.js'
export * from './profile_click_to_change.js'
export * from './profile_tts_voice.js'
export * from './profile_auto_advance.js'
export * from './profile_saving.js'
export * from './profile_saved.js'
export * from './profile_session_this.js'
export * from './profile_session_signed_in.js'
export * from './profile_session_last_seen.js'
export * from './profile_session_sign_out.js'
export * from './profile_session_end.js'
export * from './profile_session_unrecognised.js'
export * from './profile_no_sessions.js'
export * from './profile_change_password_heading.js'
export * from './profile_update_password.js'
export * from './profile_updating.js'
export * from './profile_password_changed_ok.js'
export * from './profile_playback_speed.js'
export * from './profile_subscription_heading.js'
export * from './profile_plan_pro.js'
export * from './profile_plan_free.js'
export * from './profile_pro_active.js'
export * from './profile_pro_perks.js'
export * from './profile_manage_subscription.js'
export * from './profile_upgrade_heading.js'
export * from './profile_upgrade_desc.js'
export * from './profile_upgrade_monthly.js'
export * from './profile_upgrade_annual.js'
export * from './profile_free_limits.js'
export * from './user_currently_reading.js'
export * from './user_library_count.js'
export * from './user_joined.js'
export * from './user_followers_label.js'
export * from './user_following_label.js'
export * from './user_no_books.js'
export * from './admin_pages_label.js'
export * from './admin_tools_label.js'
export * from './admin_nav_scrape.js'
export * from './admin_nav_audio.js'
export * from './admin_nav_translation.js'
export * from './admin_nav_changelog.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'
export * from './admin_nav_logs.js'
export * from './admin_nav_uptime.js'
export * from './admin_nav_push.js'
export * from './admin_scrape_status_idle.js'
export * from './admin_scrape_full_catalogue.js'
export * from './admin_scrape_single_book.js'
export * from './admin_scrape_quick_genres.js'
export * from './admin_scrape_task_history.js'
export * from './admin_scrape_filter_placeholder.js'
export * from './admin_scrape_no_matching.js'
export * from './admin_scrape_start.js'
export * from './admin_scrape_queuing.js'
export * from './admin_scrape_running.js'
export * from './admin_audio_filter_jobs.js'
export * from './admin_audio_filter_cache.js'
export * from './admin_audio_no_matching_jobs.js'
export * from './admin_audio_no_jobs.js'
export * from './admin_audio_cache_empty.js'
export * from './admin_audio_no_cache_results.js'
export * from './admin_changelog_gitea.js'
export * from './admin_changelog_no_releases.js'
export * from './admin_changelog_load_error.js'
export * from './comments_top.js'
export * from './comments_new.js'
export * from './comments_posting.js'
export * from './comments_login_link.js'
export * from './comments_login_suffix.js'
export * from './comments_anonymous.js'
export * from './reader_audio_narration.js'
export * from './reader_playing.js'
export * from './reader_paused.js'
export * from './reader_ch_ready.js'
export * from './reader_ch_preparing.js'
export * from './reader_ch_generate_on_nav.js'
export * from './reader_now_playing.js'
export * from './reader_load_this_chapter.js'
export * from './reader_generate_samples.js'
export * from './reader_voice_applies_next.js'
export * from './reader_choose_voice.js'
export * from './reader_generating_narration.js'
export * from './profile_font_family.js'
export * from './profile_font_system.js'
export * from './profile_font_serif.js'
export * from './profile_font_mono.js'
export * from './profile_text_size.js'
export * from './profile_text_size_sm.js'
export * from './profile_text_size_md.js'
export * from './profile_text_size_lg.js'
export * from './profile_text_size_xl.js'
export * from './feed_page_title.js'
export * from './feed_heading.js'
export * from './feed_subheading.js'
export * from './feed_empty_heading.js'
export * from './feed_empty_body.js'
export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_Cache_EmptyInputs */
const en_admin_audio_cache_empty = /** @type {(inputs: Admin_Audio_Cache_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio cache is empty.`)
};
const ru_admin_audio_cache_empty = /** @type {(inputs: Admin_Audio_Cache_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудиокэш пуст.`)
};
const id_admin_audio_cache_empty = /** @type {(inputs: Admin_Audio_Cache_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cache audio kosong.`)
};
const pt_admin_audio_cache_empty = /** @type {(inputs: Admin_Audio_Cache_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cache de áudio vazio.`)
};
const fr_admin_audio_cache_empty = /** @type {(inputs: Admin_Audio_Cache_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cache audio vide.`)
};
/**
* | output |
* | --- |
* | "Audio cache is empty." |
*
* @param {Admin_Audio_Cache_EmptyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_cache_empty = /** @type {((inputs?: Admin_Audio_Cache_EmptyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_Cache_EmptyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_cache_empty(inputs)
if (locale === "ru") return ru_admin_audio_cache_empty(inputs)
if (locale === "id") return id_admin_audio_cache_empty(inputs)
if (locale === "pt") return pt_admin_audio_cache_empty(inputs)
return fr_admin_audio_cache_empty(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_EmptyInputs */
const en_admin_audio_empty = /** @type {(inputs: Admin_Audio_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No audio jobs.`)
};
const ru_admin_audio_empty = /** @type {(inputs: Admin_Audio_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио задач нет.`)
};
const id_admin_audio_empty = /** @type {(inputs: Admin_Audio_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tidak ada tugas audio.`)
};
const pt_admin_audio_empty = /** @type {(inputs: Admin_Audio_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nenhuma tarefa de áudio.`)
};
const fr_admin_audio_empty = /** @type {(inputs: Admin_Audio_EmptyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucune tâche audio.`)
};
/**
* | output |
* | --- |
* | "No audio jobs." |
*
* @param {Admin_Audio_EmptyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_empty = /** @type {((inputs?: Admin_Audio_EmptyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_EmptyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_empty(inputs)
if (locale === "ru") return ru_admin_audio_empty(inputs)
if (locale === "id") return id_admin_audio_empty(inputs)
if (locale === "pt") return pt_admin_audio_empty(inputs)
return fr_admin_audio_empty(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_Filter_CacheInputs */
const en_admin_audio_filter_cache = /** @type {(inputs: Admin_Audio_Filter_CacheInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, chapter or voice…`)
};
const ru_admin_audio_filter_cache = /** @type {(inputs: Admin_Audio_Filter_CacheInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Фильтр по slug, главе или голосу…`)
};
const id_admin_audio_filter_cache = /** @type {(inputs: Admin_Audio_Filter_CacheInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter berdasarkan slug, bab, atau suara…`)
};
const pt_admin_audio_filter_cache = /** @type {(inputs: Admin_Audio_Filter_CacheInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrar por slug, capítulo ou voz…`)
};
const fr_admin_audio_filter_cache = /** @type {(inputs: Admin_Audio_Filter_CacheInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrer par slug, chapitre ou voix…`)
};
/**
* | output |
* | --- |
* | "Filter by slug, chapter or voice…" |
*
* @param {Admin_Audio_Filter_CacheInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_filter_cache = /** @type {((inputs?: Admin_Audio_Filter_CacheInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_Filter_CacheInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_filter_cache(inputs)
if (locale === "ru") return ru_admin_audio_filter_cache(inputs)
if (locale === "id") return id_admin_audio_filter_cache(inputs)
if (locale === "pt") return pt_admin_audio_filter_cache(inputs)
return fr_admin_audio_filter_cache(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_Filter_JobsInputs */
const en_admin_audio_filter_jobs = /** @type {(inputs: Admin_Audio_Filter_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by slug, voice or status…`)
};
const ru_admin_audio_filter_jobs = /** @type {(inputs: Admin_Audio_Filter_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Фильтр по slug, голосу или статусу…`)
};
const id_admin_audio_filter_jobs = /** @type {(inputs: Admin_Audio_Filter_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter berdasarkan slug, suara, atau status…`)
};
const pt_admin_audio_filter_jobs = /** @type {(inputs: Admin_Audio_Filter_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrar por slug, voz ou status…`)
};
const fr_admin_audio_filter_jobs = /** @type {(inputs: Admin_Audio_Filter_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrer par slug, voix ou statut…`)
};
/**
* | output |
* | --- |
* | "Filter by slug, voice or status…" |
*
* @param {Admin_Audio_Filter_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_filter_jobs = /** @type {((inputs?: Admin_Audio_Filter_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_Filter_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_filter_jobs(inputs)
if (locale === "ru") return ru_admin_audio_filter_jobs(inputs)
if (locale === "id") return id_admin_audio_filter_jobs(inputs)
if (locale === "pt") return pt_admin_audio_filter_jobs(inputs)
return fr_admin_audio_filter_jobs(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_HeadingInputs */
const en_admin_audio_heading = /** @type {(inputs: Admin_Audio_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio Jobs`)
};
const ru_admin_audio_heading = /** @type {(inputs: Admin_Audio_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио задачи`)
};
const id_admin_audio_heading = /** @type {(inputs: Admin_Audio_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tugas Audio`)
};
const pt_admin_audio_heading = /** @type {(inputs: Admin_Audio_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tarefas de Áudio`)
};
const fr_admin_audio_heading = /** @type {(inputs: Admin_Audio_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tâches audio`)
};
/**
* | output |
* | --- |
* | "Audio Jobs" |
*
* @param {Admin_Audio_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_heading = /** @type {((inputs?: Admin_Audio_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_heading(inputs)
if (locale === "ru") return ru_admin_audio_heading(inputs)
if (locale === "id") return id_admin_audio_heading(inputs)
if (locale === "pt") return pt_admin_audio_heading(inputs)
return fr_admin_audio_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_No_Cache_ResultsInputs */
const en_admin_audio_no_cache_results = /** @type {(inputs: Admin_Audio_No_Cache_ResultsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No results.`)
};
const ru_admin_audio_no_cache_results = /** @type {(inputs: Admin_Audio_No_Cache_ResultsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Результатов нет.`)
};
const id_admin_audio_no_cache_results = /** @type {(inputs: Admin_Audio_No_Cache_ResultsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tidak ada hasil.`)
};
const pt_admin_audio_no_cache_results = /** @type {(inputs: Admin_Audio_No_Cache_ResultsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sem resultados.`)
};
const fr_admin_audio_no_cache_results = /** @type {(inputs: Admin_Audio_No_Cache_ResultsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucun résultat.`)
};
/**
* | output |
* | --- |
* | "No results." |
*
* @param {Admin_Audio_No_Cache_ResultsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_no_cache_results = /** @type {((inputs?: Admin_Audio_No_Cache_ResultsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_No_Cache_ResultsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_no_cache_results(inputs)
if (locale === "ru") return ru_admin_audio_no_cache_results(inputs)
if (locale === "id") return id_admin_audio_no_cache_results(inputs)
if (locale === "pt") return pt_admin_audio_no_cache_results(inputs)
return fr_admin_audio_no_cache_results(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_No_JobsInputs */
const en_admin_audio_no_jobs = /** @type {(inputs: Admin_Audio_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No audio jobs yet.`)
};
const ru_admin_audio_no_jobs = /** @type {(inputs: Admin_Audio_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудиозаданий пока нет.`)
};
const id_admin_audio_no_jobs = /** @type {(inputs: Admin_Audio_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Belum ada pekerjaan audio.`)
};
const pt_admin_audio_no_jobs = /** @type {(inputs: Admin_Audio_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nenhum job de áudio ainda.`)
};
const fr_admin_audio_no_jobs = /** @type {(inputs: Admin_Audio_No_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucun job audio pour l'instant.`)
};
/**
* | output |
* | --- |
* | "No audio jobs yet." |
*
* @param {Admin_Audio_No_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_no_jobs = /** @type {((inputs?: Admin_Audio_No_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_No_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_no_jobs(inputs)
if (locale === "ru") return ru_admin_audio_no_jobs(inputs)
if (locale === "id") return id_admin_audio_no_jobs(inputs)
if (locale === "pt") return pt_admin_audio_no_jobs(inputs)
return fr_admin_audio_no_jobs(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_No_Matching_JobsInputs */
const en_admin_audio_no_matching_jobs = /** @type {(inputs: Admin_Audio_No_Matching_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching jobs.`)
};
const ru_admin_audio_no_matching_jobs = /** @type {(inputs: Admin_Audio_No_Matching_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Заданий не найдено.`)
};
const id_admin_audio_no_matching_jobs = /** @type {(inputs: Admin_Audio_No_Matching_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tidak ada pekerjaan yang cocok.`)
};
const pt_admin_audio_no_matching_jobs = /** @type {(inputs: Admin_Audio_No_Matching_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nenhum job correspondente.`)
};
const fr_admin_audio_no_matching_jobs = /** @type {(inputs: Admin_Audio_No_Matching_JobsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucun job correspondant.`)
};
/**
* | output |
* | --- |
* | "No matching jobs." |
*
* @param {Admin_Audio_No_Matching_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_no_matching_jobs = /** @type {((inputs?: Admin_Audio_No_Matching_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_No_Matching_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_no_matching_jobs(inputs)
if (locale === "ru") return ru_admin_audio_no_matching_jobs(inputs)
if (locale === "id") return id_admin_audio_no_matching_jobs(inputs)
if (locale === "pt") return pt_admin_audio_no_matching_jobs(inputs)
return fr_admin_audio_no_matching_jobs(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Audio_Page_TitleInputs */
const en_admin_audio_page_title = /** @type {(inputs: Admin_Audio_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio — Admin`)
};
const ru_admin_audio_page_title = /** @type {(inputs: Admin_Audio_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио — Админ`)
};
const id_admin_audio_page_title = /** @type {(inputs: Admin_Audio_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio — Admin`)
};
const pt_admin_audio_page_title = /** @type {(inputs: Admin_Audio_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Áudio — Admin`)
};
const fr_admin_audio_page_title = /** @type {(inputs: Admin_Audio_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio — Admin`)
};
/**
* | output |
* | --- |
* | "Audio — Admin" |
*
* @param {Admin_Audio_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_audio_page_title = /** @type {((inputs?: Admin_Audio_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Audio_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_audio_page_title(inputs)
if (locale === "ru") return ru_admin_audio_page_title(inputs)
if (locale === "id") return id_admin_audio_page_title(inputs)
if (locale === "pt") return pt_admin_audio_page_title(inputs)
return fr_admin_audio_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Changelog_GiteaInputs */
const en_admin_changelog_gitea = /** @type {(inputs: Admin_Changelog_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea releases`)
};
const ru_admin_changelog_gitea = /** @type {(inputs: Admin_Changelog_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Релизы Gitea`)
};
const id_admin_changelog_gitea = /** @type {(inputs: Admin_Changelog_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Rilis Gitea`)
};
const pt_admin_changelog_gitea = /** @type {(inputs: Admin_Changelog_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Releases do Gitea`)
};
const fr_admin_changelog_gitea = /** @type {(inputs: Admin_Changelog_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Releases Gitea`)
};
/**
* | output |
* | --- |
* | "Gitea releases" |
*
* @param {Admin_Changelog_GiteaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_changelog_gitea = /** @type {((inputs?: Admin_Changelog_GiteaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Changelog_GiteaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_changelog_gitea(inputs)
if (locale === "ru") return ru_admin_changelog_gitea(inputs)
if (locale === "id") return id_admin_changelog_gitea(inputs)
if (locale === "pt") return pt_admin_changelog_gitea(inputs)
return fr_admin_changelog_gitea(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Changelog_HeadingInputs */
const en_admin_changelog_heading = /** @type {(inputs: Admin_Changelog_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const ru_admin_changelog_heading = /** @type {(inputs: Admin_Changelog_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const id_admin_changelog_heading = /** @type {(inputs: Admin_Changelog_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const pt_admin_changelog_heading = /** @type {(inputs: Admin_Changelog_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const fr_admin_changelog_heading = /** @type {(inputs: Admin_Changelog_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
/**
* | output |
* | --- |
* | "Changelog" |
*
* @param {Admin_Changelog_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_changelog_heading = /** @type {((inputs?: Admin_Changelog_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Changelog_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_changelog_heading(inputs)
if (locale === "ru") return ru_admin_changelog_heading(inputs)
if (locale === "id") return id_admin_changelog_heading(inputs)
if (locale === "pt") return pt_admin_changelog_heading(inputs)
return fr_admin_changelog_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{ error: NonNullable<unknown> }} Admin_Changelog_Load_ErrorInputs */
const en_admin_changelog_load_error = /** @type {(inputs: Admin_Changelog_Load_ErrorInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Could not load releases: ${i?.error}`)
};
const ru_admin_changelog_load_error = /** @type {(inputs: Admin_Changelog_Load_ErrorInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Не удалось загрузить релизы: ${i?.error}`)
};
const id_admin_changelog_load_error = /** @type {(inputs: Admin_Changelog_Load_ErrorInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Gagal memuat rilis: ${i?.error}`)
};
const pt_admin_changelog_load_error = /** @type {(inputs: Admin_Changelog_Load_ErrorInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Não foi possível carregar os releases: ${i?.error}`)
};
const fr_admin_changelog_load_error = /** @type {(inputs: Admin_Changelog_Load_ErrorInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Impossible de charger les releases : ${i?.error}`)
};
/**
* | output |
* | --- |
* | "Could not load releases: {error}" |
*
* @param {Admin_Changelog_Load_ErrorInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_changelog_load_error = /** @type {((inputs: Admin_Changelog_Load_ErrorInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Changelog_Load_ErrorInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_changelog_load_error(inputs)
if (locale === "ru") return ru_admin_changelog_load_error(inputs)
if (locale === "id") return id_admin_changelog_load_error(inputs)
if (locale === "pt") return pt_admin_changelog_load_error(inputs)
return fr_admin_changelog_load_error(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Changelog_No_ReleasesInputs */
const en_admin_changelog_no_releases = /** @type {(inputs: Admin_Changelog_No_ReleasesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No releases found.`)
};
const ru_admin_changelog_no_releases = /** @type {(inputs: Admin_Changelog_No_ReleasesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Релизов не найдено.`)
};
const id_admin_changelog_no_releases = /** @type {(inputs: Admin_Changelog_No_ReleasesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tidak ada rilis.`)
};
const pt_admin_changelog_no_releases = /** @type {(inputs: Admin_Changelog_No_ReleasesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nenhum release encontrado.`)
};
const fr_admin_changelog_no_releases = /** @type {(inputs: Admin_Changelog_No_ReleasesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucune release trouvée.`)
};
/**
* | output |
* | --- |
* | "No releases found." |
*
* @param {Admin_Changelog_No_ReleasesInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_changelog_no_releases = /** @type {((inputs?: Admin_Changelog_No_ReleasesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Changelog_No_ReleasesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_changelog_no_releases(inputs)
if (locale === "ru") return ru_admin_changelog_no_releases(inputs)
if (locale === "id") return id_admin_changelog_no_releases(inputs)
if (locale === "pt") return pt_admin_changelog_no_releases(inputs)
return fr_admin_changelog_no_releases(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Changelog_Page_TitleInputs */
const en_admin_changelog_page_title = /** @type {(inputs: Admin_Changelog_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog — Admin`)
};
const ru_admin_changelog_page_title = /** @type {(inputs: Admin_Changelog_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog — Админ`)
};
const id_admin_changelog_page_title = /** @type {(inputs: Admin_Changelog_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog — Admin`)
};
const pt_admin_changelog_page_title = /** @type {(inputs: Admin_Changelog_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog — Admin`)
};
const fr_admin_changelog_page_title = /** @type {(inputs: Admin_Changelog_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog — Admin`)
};
/**
* | output |
* | --- |
* | "Changelog — Admin" |
*
* @param {Admin_Changelog_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_changelog_page_title = /** @type {((inputs?: Admin_Changelog_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Changelog_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_changelog_page_title(inputs)
if (locale === "ru") return ru_admin_changelog_page_title(inputs)
if (locale === "id") return id_admin_changelog_page_title(inputs)
if (locale === "pt") return pt_admin_changelog_page_title(inputs)
return fr_admin_changelog_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_AnalyticsInputs */
const en_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytics`)
};
const ru_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аналитика`)
};
const id_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analitik`)
};
const pt_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Análise`)
};
const fr_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytique`)
};
/**
* | output |
* | --- |
* | "Analytics" |
*
* @param {Admin_Nav_AnalyticsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AnalyticsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_analytics(inputs)
if (locale === "ru") return ru_admin_nav_analytics(inputs)
if (locale === "id") return id_admin_nav_analytics(inputs)
if (locale === "pt") return pt_admin_nav_analytics(inputs)
return fr_admin_nav_analytics(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_AudioInputs */
const en_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const ru_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио`)
};
const id_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const pt_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Áudio`)
};
const fr_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
/**
* | output |
* | --- |
* | "Audio" |
*
* @param {Admin_Nav_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_audio(inputs)
if (locale === "ru") return ru_admin_nav_audio(inputs)
if (locale === "id") return id_admin_nav_audio(inputs)
if (locale === "pt") return pt_admin_nav_audio(inputs)
return fr_admin_nav_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ChangelogInputs */
const en_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const ru_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Изменения`)
};
const id_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Perubahan`)
};
const pt_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Alterações`)
};
const fr_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Modifications`)
};
/**
* | output |
* | --- |
* | "Changelog" |
*
* @param {Admin_Nav_ChangelogInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ChangelogInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_changelog(inputs)
if (locale === "ru") return ru_admin_nav_changelog(inputs)
if (locale === "id") return id_admin_nav_changelog(inputs)
if (locale === "pt") return pt_admin_nav_changelog(inputs)
return fr_admin_nav_changelog(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ErrorsInputs */
const en_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Errors`)
};
const ru_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ошибки`)
};
const id_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kesalahan`)
};
const pt_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erros`)
};
const fr_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erreurs`)
};
/**
* | output |
* | --- |
* | "Errors" |
*
* @param {Admin_Nav_ErrorsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ErrorsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_errors(inputs)
if (locale === "ru") return ru_admin_nav_errors(inputs)
if (locale === "id") return id_admin_nav_errors(inputs)
if (locale === "pt") return pt_admin_nav_errors(inputs)
return fr_admin_nav_errors(inputs)
});

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