Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a267d8fd8 | ||
|
|
c9478a67fb | ||
|
|
1b4835daeb | ||
|
|
c9c12fc4a8 | ||
|
|
dd35024d02 | ||
|
|
4b8104f087 | ||
|
|
5da880d189 | ||
|
|
98631df47a | ||
|
|
83b3dccc41 | ||
|
|
588e455aae | ||
|
|
28ac8d8826 |
@@ -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
|
||||
|
||||
BIN
backend/backend
BIN
backend/backend
Binary file not shown.
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
181
backend/internal/libretranslate/client.go
Normal file
181
backend/internal/libretranslate/client.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
97
backend/internal/runner/translation.go
Normal file
97
backend/internal/runner/translation.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
BIN
backend/runner
Executable file
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Очень большой"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
2
ui/src/app.d.ts
vendored
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
107
ui/src/lib/server/polar.ts
Normal 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 });
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
|
||||
63
ui/src/routes/admin/translation/+page.server.ts
Normal file
63
ui/src/routes/admin/translation/+page.server.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
};
|
||||
340
ui/src/routes/admin/translation/+page.svelte
Normal file
340
ui/src/routes/admin/translation/+page.svelte
Normal 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' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
{#if stats.failed > 0}
|
||||
· <span class="text-(--color-danger)">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <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>
|
||||
@@ -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' } }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal file
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal file
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
52
ui/src/routes/api/webhooks/polar/+server.ts
Normal file
52
ui/src/routes/api/webhooks/polar/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← {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) })} →
|
||||
</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"
|
||||
>
|
||||
← {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()} →
|
||||
</a>
|
||||
|
||||
@@ -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))
|
||||
})))
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
ui/src/routes/sitemap.xml/+server.ts
Normal file
37
ui/src/routes/sitemap.xml/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user