Compare commits

...

11 Commits

Author SHA1 Message Date
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
58 changed files with 2628 additions and 288 deletions

View File

@@ -279,7 +279,7 @@ jobs:
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

Binary file not shown.

View File

@@ -150,18 +150,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,
},
)

View File

@@ -24,6 +24,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 +129,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,6 +158,7 @@ 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,
@@ -170,16 +180,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)

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

@@ -37,6 +37,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 +55,10 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
return domain.AudioTask{}, false, nil
}
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }

View File

@@ -73,6 +73,12 @@ func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int
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 {

View File

@@ -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,252 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, presignURL, http.StatusFound)
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// ── Translation ────────────────────────────────────────────────────────────────
// handleVoices handles GET /api/voices.
// 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,
})
}
// ── 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())})

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.
@@ -160,6 +162,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
// Admin translation endpoints
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -141,3 +141,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

@@ -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
@@ -135,15 +149,16 @@ type Runner struct {
// 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
}
@@ -166,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{
@@ -195,6 +211,7 @@ 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),

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

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

@@ -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
@@ -95,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.
@@ -107,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
}
@@ -137,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"
}
@@ -175,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,
)
@@ -208,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)
@@ -227,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():
@@ -252,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.
@@ -335,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

@@ -48,6 +48,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 +62,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

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)
@@ -125,6 +127,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 {

View File

@@ -51,6 +51,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)
@@ -535,13 +536,36 @@ 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)})
}
@@ -571,6 +595,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 +635,18 @@ func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.Au
})
}
func (s *Store) FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error {
status := string(domain.TaskStatusDone)
if result.ErrorMessage != "" {
status = string(domain.TaskStatusFailed)
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), map[string]any{
"status": status,
"error_message": result.ErrorMessage,
"finished": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
payload := map[string]any{
"status": string(domain.TaskStatusFailed),
@@ -608,11 +656,14 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
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 +671,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
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 +692,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 +769,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 +868,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 +929,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,6 +29,10 @@ 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
@@ -46,13 +50,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 +93,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,6 +23,9 @@ 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) 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) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
@@ -31,12 +34,18 @@ func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.Scr
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
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 +62,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)

BIN
backend/runner Executable file

Binary file not shown.

View File

@@ -401,15 +401,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:

View File

@@ -221,7 +221,7 @@ 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}"
@@ -443,15 +443,19 @@ 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:

View File

@@ -12,6 +12,7 @@
# - 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)
# - LibreTranslate service for machine translation (internal network only)
services:
redis:
@@ -29,6 +30,26 @@ services:
timeout: 5s
retries: 5
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
# Limit to source→target pairs the runner actually uses
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
@@ -36,6 +57,8 @@ services:
depends_on:
redis:
condition: service_healthy
libretranslate:
condition: service_healthy
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
@@ -64,6 +87,10 @@ services:
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (local service) ───────────────────────────────────────
# The runner connects to the local Redis sidecar.
REDIS_ADDR: "redis:6379"
@@ -74,6 +101,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"
@@ -90,3 +118,5 @@ services:
volumes:
redis_data:
libretranslate_models:
libretranslate_db:

View File

@@ -245,6 +245,20 @@ 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"}
]}'
# ── 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 +272,7 @@ 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"
log "done"

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -329,6 +333,18 @@
"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}",
@@ -380,5 +396,15 @@
"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"
"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"
}

View File

@@ -48,6 +48,7 @@
"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é",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -329,6 +333,18 @@
"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}",
@@ -379,5 +395,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -329,6 +333,18 @@
"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}",
@@ -379,5 +395,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -329,6 +333,18 @@
"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}",
@@ -379,5 +395,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Воспроизвести",
"player_pause": "Пауза",
"player_speed_label": "Скорость {speed}x",
"player_seek_label": "Прогресс главы",
"player_change_speed": "Изменить скорость",
"player_auto_next_on": "Автопереход вкл.",
"player_auto_next_off": "Автопереход выкл.",
@@ -99,6 +100,7 @@
"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": "Читать",
@@ -123,6 +125,8 @@
"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": "Следующая глава",
@@ -329,6 +333,18 @@
"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}",
@@ -379,5 +395,15 @@
"reader_generate_samples": "Сгенерировать недостающие образцы",
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…"
"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": "Очень большой"
}

View File

@@ -7,7 +7,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},

View File

@@ -56,11 +56,18 @@ 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;
font-family: var(--reading-font);
font-size: var(--reading-size);
color: var(--color-muted);
}

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

@@ -6,7 +6,7 @@ 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';
@@ -210,6 +210,18 @@ const appHandle: 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);
};

View File

@@ -67,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 {
@@ -77,7 +79,8 @@
cover = '',
nextChapter = null,
chapters = [],
voices = []
voices = [],
onProRequired = undefined
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -563,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) {

View File

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

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

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

View File

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

View File

@@ -10,7 +10,7 @@
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.js';
import { locales, getLocale } from '$lib/paraglide/runtime.js';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -26,11 +26,17 @@
// ── Theme ──────────────────────────────────────────────────────────────
let currentTheme = $state(data.settings?.theme ?? 'amber');
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
// Expose theme state to child pages (e.g. profile theme picker)
// Expose theme + font state to child pages (e.g. profile picker)
setContext('theme', {
get current() { return currentTheme; },
set current(v: string) { currentTheme = v; }
set current(v: string) { currentTheme = v; },
get fontFamily() { return currentFontFamily; },
set fontFamily(v: string) { currentFontFamily = v; },
get fontSize() { return currentFontSize; },
set fontSize(v: number) { currentFontSize = v; }
});
$effect(() => {
@@ -39,6 +45,17 @@
}
});
$effect(() => {
if (typeof document === 'undefined') return;
const fontMap: Record<string, string> = {
system: 'system-ui, -apple-system, sans-serif',
serif: "Georgia, 'Times New Roman', serif",
mono: "'Courier New', monospace",
};
document.documentElement.style.setProperty('--reading-font', fontMap[currentFontFamily] ?? fontMap.system);
document.documentElement.style.setProperty('--reading-size', `${currentFontSize}rem`);
});
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
@@ -50,19 +67,23 @@
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
// Always sync theme (profile page calls invalidateAll after saving)
// Always sync theme + font (profile page calls invalidateAll after saving)
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
currentFontSize = data.settings.fontSize ?? 1.0;
}
});
// ── Persist settings changes (debounced 800ms) ──────────────────────────
let settingsSaveTimer = 0;
$effect(() => {
// Subscribe to the four settings fields
// Subscribe to settings fields
const autoNext = audioStore.autoNext;
const voice = audioStore.voice;
const speed = audioStore.speed;
const theme = currentTheme;
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
@@ -72,7 +93,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -168,7 +189,7 @@
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
@@ -283,6 +304,40 @@
</a>
<div class="ml-auto flex items-center gap-4">
<!-- Theme quick picker -->
<div class="hidden sm:flex items-center gap-1">
{#each [{ id: 'amber', color: '#f59e0b' }, { id: 'slate', color: '#818cf8' }, { id: 'rose', color: '#fb7185' }] as t}
<button
type="button"
onclick={() => { currentTheme = t.id; }}
title={t.id}
class="w-4 h-4 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-60 hover:opacity-100'}"
style="background: {t.color};"
></button>
{/each}
</div>
<!-- Language quick picker -->
<div class="hidden sm:flex items-center gap-0.5">
{#each locales as locale}
<button
type="button"
onclick={async () => {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{locale.toUpperCase()}
</button>
{/each}
</div>
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
{#if data.user?.role === 'admin'}
<a
@@ -432,19 +487,25 @@
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Locale switcher (always visible) -->
<!-- Locale switcher (footer) -->
<div class="hidden sm:flex items-center gap-1 ml-2">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
data-sveltekit-reload
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale
? 'text-(--color-brand) bg-(--color-brand)/10'
: 'text-(--color-muted) hover:text-(--color-text)'}"
<button
type="button"
onclick={async () => {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
}).catch(() => {});
const { setLocale } = await import('$lib/paraglide/runtime.js');
setLocale(locale as any, { reload: true });
}}
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
aria-label="{m.locale_switcher_label()}: {locale}"
>
{locale.toUpperCase()}
</a>
</button>
{/each}
</div>

View File

@@ -5,6 +5,7 @@
const internalLinks = [
{ href: '/admin/scrape', label: 'Scrape' },
{ href: '/admin/audio', label: 'Audio' },
{ href: '/admin/translation', label: 'Translation' },
{ href: '/admin/changelog', label: 'Changelog' }
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/translation/status/[slug]/[n]?lang=<lang>
* Proxies the translation status check to the backend.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const lang = url.searchParams.get('lang') ?? '';
const res = await backendFetch(
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (!res.ok) {
return new Response(JSON.stringify({ status: 'idle' }), {
headers: { 'Content-Type': 'application/json' }
});
}
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,52 @@
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
/**
* POST /api/webhooks/polar
*
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
* Signature is verified via HMAC-SHA256 before any processing.
*/
export const POST: RequestHandler = async ({ request }) => {
const rawBody = await request.text();
const signature = request.headers.get('webhook-signature') ?? '';
if (!verifyPolarWebhook(rawBody, signature)) {
log.warn('polar', 'webhook signature verification failed');
return new Response('Unauthorized', { status: 401 });
}
let event: { type: string; data: Record<string, unknown> };
try {
event = JSON.parse(rawBody);
} catch {
return new Response('Bad Request', { status: 400 });
}
const { type, data } = event;
log.info('polar', 'webhook received', { type });
try {
switch (type) {
case 'subscription.created':
case 'subscription.updated':
case 'subscription.revoked':
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
break;
case 'order.created':
// One-time purchases — no role change needed for now
log.info('polar', 'order.created (no action)', { orderId: data.id });
break;
default:
log.debug('polar', 'unhandled webhook event type', { type });
}
} catch (err) {
// Log but return 200 — Polar retries on non-2xx, we don't want retry storms
log.error('polar', 'webhook handler error', { type, err: String(err) });
}
return new Response('OK', { status: 200 });
};

View File

@@ -25,7 +25,7 @@ import {
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
@@ -227,12 +227,21 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
);
// ── Create session + auth cookie ──────────────────────────────────────────
const authSessionId = randomBytes(16).toString('hex');
const userAgent = '' ; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
let authSessionId: string;
// Reuse existing session if the user is already logged in as the same user
if (locals.user?.id === user.id && locals.user?.authSessionId) {
authSessionId = locals.user.authSessionId;
// Just touch the existing session to update last_seen
touchUserSession(authSessionId).catch(() => {});
} else {
authSessionId = randomBytes(16).toString('hex');
const userAgent = ''; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {

View File

@@ -141,7 +141,7 @@
<p class="text-(--color-muted) text-sm mt-1">
{m.book_detail_scraping_progress()}
</p>
{#if data.taskId}
{#if data.taskId && data.user?.role === 'admin'}
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
{/if}
</div>
@@ -243,7 +243,17 @@
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if data.inLib}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
@@ -294,7 +304,17 @@
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if data.inLib}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}

View File

@@ -6,6 +6,8 @@ import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import type { Voice } from '$lib/types';
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
export const load: PageServerLoad = async ({ params, url, locals }) => {
const { slug } = params;
const n = parseInt(params.n, 10);
@@ -15,6 +17,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const isPreview = url.searchParams.get('preview') === '1';
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
const chapterTitle = url.searchParams.get('title') ?? '';
const rawLang = url.searchParams.get('lang') ?? '';
// Non-pro users can only read EN — silently ignore lang param
const lang = locals.isPro && SUPPORTED_LANGS.has(rawLang) ? rawLang : '';
const useTranslation = lang !== '';
if (isPreview) {
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
@@ -77,7 +83,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
next: null as number | null,
chapters: [] as { number: number; title: string }[],
sessionId: locals.sessionId,
isPreview: true
isPreview: true,
lang: '',
translationStatus: 'unavailable' as string,
isPro: locals.isPro
};
}
@@ -105,7 +114,38 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
// Non-critical — UI will use store default
}
// Fetch chapter markdown directly from the backend (server-side MinIO read)
// ── Translation path: try to serve translated HTML ─────────────────────
if (useTranslation) {
try {
const tRes = await backendFetch(
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (tRes.ok) {
const tData = (await tRes.json()) as { html: string; lang: string };
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
chapter: chapterIdx,
html: tData.html,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false,
lang,
translationStatus: 'done',
isPro: locals.isPro
};
}
// 404 = not generated yet — fall through to original, UI can trigger generation
} catch {
// Non-critical — fall through to original content
}
}
// ── Original content path ──────────────────────────────────────────────
let html = '';
try {
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
@@ -122,6 +162,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
error(502, 'Could not fetch chapter content');
}
// Check translation status for the UI switcher (non-blocking)
let translationStatus = 'idle';
if (useTranslation) {
try {
const stRes = await backendFetch(
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
);
if (stRes.ok) {
const stData = (await stRes.json()) as { status: string };
translationStatus = stData.status ?? 'idle';
}
} catch {
// Non-critical
}
}
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
@@ -134,6 +190,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false
isPreview: false,
lang: useTranslation ? lang : '',
translationStatus,
isPro: locals.isPro
};
};

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -9,8 +11,80 @@
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let fetchError = $state('');
let audioProRequired = $state(false);
onMount(async () => {
// Translation state
const SUPPORTED_LANGS = [
{ code: 'ru', label: 'RU' },
{ code: 'id', label: 'ID' },
{ code: 'pt', label: 'PT' },
{ code: 'fr', label: 'FR' }
];
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
let translatingLang = $state(untrack(() => data.lang ?? ''));
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
function currentLang() {
return page.url.searchParams.get('lang') ?? '';
}
function langUrl(lang: string) {
const u = new URL(page.url);
if (lang) u.searchParams.set('lang', lang);
else u.searchParams.delete('lang');
return u.pathname + u.search;
}
async function requestTranslation(lang: string) {
if (!data.isPro) {
// Don't even attempt — show upgrade inline
return;
}
translatingLang = lang;
translationStatus = 'pending';
try {
const res = await fetch(
`/api/translation/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`,
{ method: 'POST' }
);
if (res.status === 402) {
translationStatus = 'idle';
translatingLang = '';
return;
}
const d = (await res.json()) as { status: string };
translationStatus = d.status ?? 'pending';
if (d.status === 'done') {
goto(langUrl(lang));
} else {
startPolling(lang);
}
} catch {
translationStatus = 'idle';
}
}
function startPolling(lang: string) {
if (pollingTimer) clearTimeout(pollingTimer);
pollingTimer = setTimeout(async () => {
try {
const res = await fetch(
`/api/translation/status/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`
);
const d = (await res.json()) as { status: string };
translationStatus = d.status ?? 'idle';
if (d.status === 'done') {
goto(langUrl(lang));
} else if (d.status === 'pending' || d.status === 'running') {
startPolling(lang);
}
} catch {
startPolling(lang); // retry on network error
}
}, 3000);
}
onMount(() => {
// Umami analytics: track chapter reads
window.umami?.track('chapter_read', {
slug: data.book.slug,
@@ -20,7 +94,7 @@
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
try {
await fetch('/api/progress', {
fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
@@ -30,26 +104,37 @@
}
}
// Resume polling if translation was in-progress at load time
if (translatingLang && (translationStatus === 'pending' || translationStatus === 'running')) {
startPolling(translatingLang);
}
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
try {
const res = await fetch(
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
);
if (!res.ok) throw new Error(`status ${res.status}`);
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
} else {
fetchError = m.reader_audio_error();
(async () => {
try {
const res = await fetch(
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
);
if (!res.ok) throw new Error(`status ${res.status}`);
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
} else {
fetchError = m.reader_audio_error();
}
} catch {
fetchError = m.reader_fetching_chapter();
} finally {
fetchingContent = false;
}
} catch (e) {
fetchError = m.reader_fetching_chapter();
} finally {
fetchingContent = false;
}
})();
}
return () => {
if (pollingTimer) clearTimeout(pollingTimer);
};
});
const wordCount = $derived(
@@ -77,7 +162,7 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_chapter_n({ n: String(data.prev) })}
</a>
@@ -85,7 +170,7 @@
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_chapter_n({ n: String(data.next) })} &rarr;
</a>
@@ -94,17 +179,106 @@
</div>
<!-- Chapter heading -->
<div class="mb-6">
<div class="mb-4">
<h1 class="text-xl font-bold text-(--color-text)">
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
</h1>
{#if wordCount > 0}
<p class="text-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</p>
<p class="text-(--color-muted) text-xs mt-1">
{m.reader_words({ n: wordCount.toLocaleString() })}
<span class="opacity-50 mx-1">·</span>
~{Math.max(1, Math.round(wordCount / 200))} min read
</p>
{/if}
</div>
<!-- Language switcher (not shown for preview chapters) -->
{#if !data.isPreview}
<div class="flex items-center gap-2 mb-6 flex-wrap">
<span class="text-(--color-muted) text-xs">Read in:</span>
<!-- English (original) -->
<a
href={langUrl('')}
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
>
EN
</a>
{#each SUPPORTED_LANGS as { code, label }}
{#if !data.isPro}
<!-- Locked for free users -->
<a
href="/profile"
title="Upgrade to Pro to read in {label}"
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
</svg>
{label}
</a>
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
<!-- Spinning indicator while translating -->
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
<svg class="w-3 h-3 animate-spin" 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>
{label}
</span>
{:else if currentLang() === code}
<!-- Active translated lang -->
<a
href={langUrl(code)}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
>{label}</a>
{:else}
<!-- Inactive lang: click to request/navigate -->
<button
onclick={() => requestTranslation(code)}
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>{label}</button>
{/if}
{/each}
{#if !data.isPro}
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
{/if}
</div>
{/if}
<!-- Audio player -->
{#if !data.isPreview}
{#if !page.data.user}
<!-- Unauthenticated: sign-in prompt -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
</div>
<a
href="/login"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
{m.nav_sign_in()}
</a>
</div>
{:else if audioProRequired}
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
<div>
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
</div>
<a
href="/profile"
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
>
Upgrade
</a>
</div>
{:else}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
@@ -114,7 +288,9 @@
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
{:else}
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
{m.reader_preview_audio_notice()}
@@ -145,17 +321,17 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {m.reader_prev_chapter()}
</a>
{:else}
<div></div>
<span></span>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_next_chapter()} &rarr;
</a>

View File

@@ -117,11 +117,15 @@
{ value: 'rank', label: m.catalogue_sort_rank() }
]);
const FALLBACK_STATUSES = ['ongoing', 'completed'];
const STATUS_LABELS: Record<string, () => string> = {
ongoing: () => m.catalogue_status_ongoing(),
completed: () => m.catalogue_status_completed(),
};
const statuses = $derived([
{ value: 'all', label: m.catalogue_status_all() },
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1)
label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1))
})))
]);

View File

@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -38,40 +38,3 @@ export const load: PageServerLoad = async ({ locals }) => {
}))
};
};
export const actions: Actions = {
changePassword: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not logged in.' });
}
const data = await request.formData();
const current = (data.get('current') as string | null) ?? '';
const next = (data.get('next') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!current || !next || !confirm) {
return fail(400, { error: 'All fields are required.' });
}
if (next.length < 8) {
return fail(400, { error: 'New password must be at least 8 characters.' });
}
if (next !== confirm) {
return fail(400, { error: 'New passwords do not match.' });
}
let ok: boolean;
try {
ok = await changePassword(locals.user.id, current, next);
} catch (e) {
log.error('profile', 'changePassword failed', { err: String(e) });
return fail(500, { error: 'An error occurred. Please try again.' });
}
if (!ok) {
return fail(401, { error: 'Current password is incorrect.' });
}
return { success: true };
}
};

View File

@@ -90,9 +90,11 @@
autoNext = audioStore.autoNext;
});
// ── Theme ────────────────────────────────────────────────────────────────────
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
// ── Theme + Font ─────────────────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
@@ -100,6 +102,19 @@
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
];
const FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
@@ -110,14 +125,18 @@
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme })
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme, fontFamily: selectedFontFamily, fontSize: selectedFontSize })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
// Apply theme live via context
themeCtx?.setTheme(selectedTheme);
// Apply theme + font live via context
if (settingsCtx) {
settingsCtx.current = selectedTheme;
settingsCtx.fontFamily = selectedFontFamily;
settingsCtx.fontSize = selectedFontSize;
}
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
@@ -126,17 +145,6 @@
}
}
// ── Password change ─────────────────────────────────────────────────────────
let pwSubmitting = $state(false);
let pwSuccess = $state(false);
$effect(() => {
if (form?.success) {
pwSuccess = true;
setTimeout(() => (pwSuccess = false), 3000);
}
});
// ── Sessions ────────────────────────────────────────────────────────────────
type Session = {
id: string;
@@ -275,6 +283,69 @@
</div>
</div>
<!-- ── Subscription ─────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<div class="flex items-center justify-between gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
{#if data.isPro}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-400/15 text-amber-400 border border-amber-400/30 tracking-wide uppercase">
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_plan_pro()}
</span>
{:else}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
{/if}
</div>
{#if data.isPro}
<p class="text-sm text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted)">{m.profile_pro_perks()}</p>
<a
href="https://polar.sh/libnovel"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline"
>
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
{:else}
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
<div>
<p class="text-sm font-semibold text-(--color-text) mb-3">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a
href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
</svg>
{m.profile_upgrade_monthly()}
</a>
<a
href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
>
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-amber-400/15 text-amber-400 border border-amber-400/30">33%</span>
</a>
</div>
</div>
{/if}
</section>
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
@@ -303,6 +374,51 @@
{/each}
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONTS as f}
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontFamily === f.id ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontFamily === f.id}
>
{f.label()}
</button>
{/each}
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s}
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors {selectedFontSize === s.value ? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)' : 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontSize === s.value}
>
{s.label()}
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
@@ -363,16 +479,19 @@
</div>
</div>
<!-- Auto-next -->
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={autoNext}
style="accent-color: var(--color-brand);"
class="w-4 h-4 rounded"
/>
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
</label>
<!-- Auto-next toggle -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</span>
<button
type="button"
role="switch"
aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)}
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button>
</div>
<div class="flex items-center gap-3 pt-1">
<button
@@ -437,75 +556,4 @@
</ul>
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_change_password_heading()}</h2>
{#if form?.error}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{form.error}
</div>
{/if}
{#if pwSuccess}
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
{m.profile_password_changed_ok()}
</div>
{/if}
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
pwSubmitting = true;
return async ({ update }) => {
pwSubmitting = false;
await update();
};
}}
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
/>
</div>
<button
type="submit"
disabled={pwSubmitting}
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
>
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
</button>
</form>
</section>
</div>

View File

@@ -0,0 +1,37 @@
import type { RequestHandler } from './$types';
const SITE = 'https://libnovel.cc';
// Only static public pages are listed here. Book/catalogue pages are
// discoverable via the catalogue link and don't need individual entries
// (the catalogue itself serves as an index for crawlers).
const PUBLIC_PAGES = [
{ path: '/catalogue', changefreq: 'daily', priority: '0.9' },
{ path: '/login', changefreq: 'monthly', priority: '0.5' },
{ path: '/disclaimer', changefreq: 'yearly', priority: '0.2' },
{ path: '/privacy', changefreq: 'yearly', priority: '0.2' },
{ path: '/dmca', changefreq: 'yearly', priority: '0.2' },
];
export const GET: RequestHandler = () => {
const urls = PUBLIC_PAGES.map(
({ path, changefreq, priority }) => `
<url>
<loc>${SITE}${path}</loc>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
</url>`
).join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -1,3 +1,14 @@
# allow crawling everything by default
User-agent: *
Disallow:
Disallow: /books
Disallow: /profile
Disallow: /admin/
Disallow: /api/
Disallow: /auth/
Allow: /books/
Allow: /catalogue
Allow: /login
Allow: /disclaimer
Allow: /privacy
Allow: /dmca
Sitemap: https://libnovel.cc/sitemap.xml