Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6456e8cf5d | ||
|
|
25150c2284 | ||
|
|
0e0a70a786 | ||
|
|
bdbe48ce1a | ||
|
|
87c541b178 | ||
|
|
0b82d96798 | ||
|
|
a2dd0681d2 | ||
|
|
ad50bd21ea | ||
|
|
6572e7c849 | ||
|
|
74ece7e94e | ||
|
|
d1b7d3e36c | ||
|
|
aaa008ac99 | ||
|
|
9806f0d894 | ||
|
|
e862135775 | ||
|
|
8588475d03 | ||
|
|
28fee7aee3 | ||
|
|
a888d9a0f5 | ||
|
|
ac7b686fba | ||
|
|
24d73cb730 | ||
|
|
19aeb90403 | ||
|
|
06d4a7bfd4 | ||
|
|
73a92ccf8f | ||
|
|
08361172c6 | ||
|
|
809dc8d898 | ||
|
|
e9c3426fbe | ||
|
|
8e611840d1 | ||
|
|
b9383570e3 | ||
|
|
eac9358c6f | ||
|
|
9cb11bc5e4 | ||
|
|
7196f8e930 | ||
|
|
a771405db8 | ||
|
|
1e9a96aa0f | ||
|
|
23ae1ed500 | ||
|
|
e7cb460f9b | ||
|
|
392248e8a6 | ||
|
|
68ea2d2808 | ||
|
|
7b1df9b592 | ||
|
|
f4089fe111 | ||
|
|
87b5ad1460 | ||
|
|
168cb52ed0 | ||
|
|
e1621a3ec2 | ||
|
|
10c7a48bc6 | ||
|
|
8b597c0bd2 | ||
|
|
28cafe2aa8 | ||
|
|
65f0425b61 | ||
|
|
4e70a2981d | ||
|
|
004cb95e56 | ||
|
|
aca649039c | ||
|
|
8d95411139 | ||
|
|
f9a4a0e416 | ||
|
|
a4d94f522a |
@@ -2,11 +2,8 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
pull_request:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
|
||||
@@ -190,17 +190,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch releases from Gitea API
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESPONSE=$(curl -sfL \
|
||||
-H "Accept: application/json" \
|
||||
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
|
||||
# Validate JSON before writing — fails hard if response is not a JSON array
|
||||
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
|
||||
echo "$RESPONSE" > ui/static/releases.json
|
||||
echo "Fetched $COUNT releases"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
@@ -278,8 +267,25 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract release notes from tag commit
|
||||
id: notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Subject line (first line of commit message) → release title
|
||||
SUBJECT=$(git log -1 --format="%s" "${{ gitea.sha }}")
|
||||
# Body (everything after the blank line) → release body
|
||||
BODY=$(git log -1 --format="%b" "${{ gitea.sha }}" | sed '/^Co-Authored-By:/d' | sed '/^[[:space:]]*$/{ N; /^\n$/d }' | sed 's/^[[:space:]]*$//' | awk 'NF || !p; {p = !NF}')
|
||||
echo "title=${SUBJECT}" >> "$GITHUB_OUTPUT"
|
||||
# Use a heredoc delimiter to safely handle multi-line body
|
||||
{
|
||||
echo "body<<RELEASE_BODY_EOF"
|
||||
echo "${BODY}"
|
||||
echo "RELEASE_BODY_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
generate_release_notes: true
|
||||
title: ${{ steps.notes.outputs.title }}
|
||||
body: ${{ steps.notes.outputs.body }}
|
||||
|
||||
14
.githooks/pre-commit
Executable file
14
.githooks/pre-commit
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Auto-recompile paraglide messages when ui/messages/*.json files are staged.
|
||||
# Prevents svelte-check / CI failures caused by stale generated JS files.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STAGED=$(git diff --cached --name-only)
|
||||
|
||||
if echo "$STAGED" | grep -q '^ui/messages/'; then
|
||||
echo "[pre-commit] ui/messages/*.json changed — recompiling paraglide..."
|
||||
(cd ui && npm run paraglide --silent)
|
||||
git add -f ui/src/lib/paraglide/messages/
|
||||
echo "[pre-commit] paraglide output re-staged."
|
||||
fi
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,8 @@
|
||||
|
||||
# ── Compiled binaries ──────────────────────────────────────────────────────────
|
||||
backend/bin/
|
||||
backend/backend
|
||||
backend/runner
|
||||
|
||||
# ── Environment & secrets ──────────────────────────────────────────────────────
|
||||
# Secrets are managed by Doppler — never commit .env files.
|
||||
|
||||
28
Caddyfile
28
Caddyfile
@@ -58,16 +58,22 @@
|
||||
|
||||
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
|
||||
# Exposes prod Redis over TLS for Asynq job enqueueing from the homelab runner.
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# for redis.libnovel.cc; traffic is proxied to the local Redis sidecar.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
layer4 {
|
||||
:6380 {
|
||||
route {
|
||||
tls {
|
||||
proxy {
|
||||
connection_policy {
|
||||
match {
|
||||
sni redis.libnovel.cc
|
||||
}
|
||||
}
|
||||
}
|
||||
proxy {
|
||||
upstream redis:6379
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +217,11 @@
|
||||
file_server
|
||||
}
|
||||
handle_errors 500 {
|
||||
root * /srv/errors
|
||||
rewrite * /500.html
|
||||
file_server
|
||||
}
|
||||
handle_errors 502 {
|
||||
root * /srv/errors
|
||||
rewrite * /502.html
|
||||
file_server
|
||||
@@ -269,3 +280,12 @@ search.libnovel.cc {
|
||||
reverse_proxy meilisearch:7700
|
||||
}
|
||||
|
||||
# ── Redis TLS cert anchor ─────────────────────────────────────────────────────
|
||||
# This virtual host exists solely so Caddy obtains and caches a TLS certificate
|
||||
# for redis.libnovel.cc. The layer4 block above uses that cert to terminate TLS
|
||||
# on :6380 (Asynq job-queue channel from prod → homelab Redis).
|
||||
# The HTTP route itself just returns 404 — no real traffic expected here.
|
||||
redis.libnovel.cc {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
BIN
backend/backend
BIN
backend/backend
Binary file not shown.
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/asynqqueue"
|
||||
"github.com/libnovel/backend/internal/backend"
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
@@ -114,6 +115,33 @@ func run() error {
|
||||
log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
|
||||
}
|
||||
|
||||
// ── Cloudflare Workers AI (voice sample generation + audio-stream live TTS) ──
|
||||
var cfaiClient cfai.Client
|
||||
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
||||
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
|
||||
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
|
||||
} else {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend")
|
||||
}
|
||||
|
||||
// ── Cloudflare Workers AI Image Generation ────────────────────────────────
|
||||
var imageGenClient cfai.ImageGenClient
|
||||
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
||||
imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
|
||||
log.Info("cloudflare AI image generation enabled")
|
||||
} else {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
|
||||
}
|
||||
|
||||
// ── Cloudflare Workers AI Text Generation ─────────────────────────────────
|
||||
var textGenClient cfai.TextGenClient
|
||||
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
||||
textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
|
||||
log.Info("cloudflare AI text generation enabled")
|
||||
} else {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable")
|
||||
}
|
||||
|
||||
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
|
||||
var searchIndex meili.Client
|
||||
if cfg.Meilisearch.URL != "" {
|
||||
@@ -134,7 +162,7 @@ func run() error {
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
|
||||
}
|
||||
asynqProducer := asynqqueue.NewProducer(store, redisOpt)
|
||||
asynqProducer := asynqqueue.NewProducer(store, redisOpt, log)
|
||||
defer asynqProducer.Close() //nolint:errcheck
|
||||
producer = asynqProducer
|
||||
log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
|
||||
@@ -163,6 +191,10 @@ func run() error {
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
ImageGen: imageGenClient,
|
||||
TextGen: textGenClient,
|
||||
BookWriter: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
@@ -200,6 +232,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/libnovel/backend/internal/asynqqueue"
|
||||
"github.com/libnovel/backend/internal/browser"
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/libretranslate"
|
||||
@@ -130,6 +131,15 @@ func run() error {
|
||||
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
|
||||
}
|
||||
|
||||
// ── Cloudflare Workers AI ────────────────────────────────────────────────
|
||||
var cfaiClient cfai.Client
|
||||
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
|
||||
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
|
||||
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
|
||||
} else {
|
||||
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voice tasks will fail")
|
||||
}
|
||||
|
||||
// ── LibreTranslate ──────────────────────────────────────────────────────
|
||||
ltClient := libretranslate.New(cfg.LibreTranslate.URL, cfg.LibreTranslate.APIKey)
|
||||
if ltClient != nil {
|
||||
@@ -191,6 +201,7 @@ func run() error {
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Log: log,
|
||||
}
|
||||
@@ -227,6 +238,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
|
||||
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
|
||||
//
|
||||
// When using Asynq, the runner no longer polls for work — Asynq delivers
|
||||
// tasks via the ServeMux handlers. The only Consumer operations the handlers
|
||||
// need are:
|
||||
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
|
||||
// - FailTask — mark PocketBase record as failed
|
||||
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
|
||||
// delivers those tasks via the ServeMux handlers. However translation tasks
|
||||
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
|
||||
// still delegate to the underlying PocketBase consumer.
|
||||
//
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
|
||||
// are all no-ops here because Asynq owns those responsibilities.
|
||||
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
|
||||
// those responsibilities.
|
||||
type Consumer struct {
|
||||
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
|
||||
}
|
||||
@@ -55,10 +54,18 @@ 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
|
||||
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
|
||||
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
|
||||
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
|
||||
return c.pb.ClaimNextTranslationTask(ctx, workerID)
|
||||
}
|
||||
|
||||
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
|
||||
return c.pb.HeartbeatTask(ctx, id)
|
||||
}
|
||||
|
||||
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
|
||||
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
|
||||
// to pending and can be reclaimed.
|
||||
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
|
||||
return c.pb.ReapStaleTasks(ctx, staleAfter)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
@@ -14,13 +15,15 @@ import (
|
||||
type Producer struct {
|
||||
pb taskqueue.Producer // underlying PocketBase producer
|
||||
client *asynq.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewProducer wraps an existing PocketBase Producer with Asynq dispatch.
|
||||
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt) *Producer {
|
||||
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt, log *slog.Logger) *Producer {
|
||||
return &Producer{
|
||||
pb: pb,
|
||||
client: asynq.NewClient(redisOpt),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +52,9 @@ func (p *Producer) CreateScrapeTask(ctx context.Context, kind, targetURL string,
|
||||
}
|
||||
if err := p.enqueue(ctx, taskType, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
return id, fmt.Errorf("asynq enqueue scrape (task still in PB): %w", err)
|
||||
p.log.Warn("asynq enqueue scrape failed (task still in PB, runner will poll)",
|
||||
"task_id", id, "err", err)
|
||||
return id, nil
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -68,7 +73,10 @@ func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int
|
||||
Voice: voice,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeAudioGenerate, payload); err != nil {
|
||||
return id, fmt.Errorf("asynq enqueue audio (task still in PB): %w", err)
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
p.log.Warn("asynq enqueue audio failed (task still in PB, runner will poll)",
|
||||
"task_id", id, "err", err)
|
||||
return id, nil
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -85,6 +93,12 @@ func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
return p.pb.CancelTask(ctx, id)
|
||||
}
|
||||
|
||||
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
|
||||
// audio tasks for slug.
|
||||
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
return p.pb.CancelAudioTasksBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
// enqueue serialises payload and dispatches it to Asynq.
|
||||
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
|
||||
143
backend/internal/backend/epub.go
Normal file
143
backend/internal/backend/epub.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type epubChapter struct {
|
||||
Number int
|
||||
Title string
|
||||
HTML string
|
||||
}
|
||||
|
||||
func generateEPUB(slug, title, author string, chapters []epubChapter) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
|
||||
// 1. mimetype — MUST be first, MUST be uncompressed (Store method)
|
||||
mw, err := w.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mw.Write([]byte("application/epub+zip"))
|
||||
|
||||
// 2. META-INF/container.xml
|
||||
addFile(w, "META-INF/container.xml", containerXML())
|
||||
|
||||
// 3. OEBPS/style.css
|
||||
addFile(w, "OEBPS/style.css", epubCSS())
|
||||
|
||||
// 4. OEBPS/content.opf
|
||||
addFile(w, "OEBPS/content.opf", contentOPF(slug, title, author, chapters))
|
||||
|
||||
// 5. OEBPS/toc.ncx
|
||||
addFile(w, "OEBPS/toc.ncx", tocNCX(slug, title, chapters))
|
||||
|
||||
// 6. Chapter files
|
||||
for _, ch := range chapters {
|
||||
name := fmt.Sprintf("OEBPS/chapter-%04d.xhtml", ch.Number)
|
||||
addFile(w, name, chapterXHTML(ch))
|
||||
}
|
||||
|
||||
w.Close()
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func addFile(w *zip.Writer, name, content string) {
|
||||
f, _ := w.Create(name)
|
||||
f.Write([]byte(content))
|
||||
}
|
||||
|
||||
func containerXML() string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
}
|
||||
|
||||
func contentOPF(slug, title, author string, chapters []epubChapter) string {
|
||||
var items, spine strings.Builder
|
||||
for _, ch := range chapters {
|
||||
id := fmt.Sprintf("ch%04d", ch.Number)
|
||||
href := fmt.Sprintf("chapter-%04d.xhtml", ch.Number)
|
||||
items.WriteString(fmt.Sprintf(` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, href))
|
||||
spine.WriteString(fmt.Sprintf(` <itemref idref="%s"/>`+"\n", id))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>%s</dc:title>
|
||||
<dc:creator>%s</dc:creator>
|
||||
<dc:identifier id="uid">%s</dc:identifier>
|
||||
<dc:language>en</dc:language>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="css" href="style.css" media-type="text/css"/>
|
||||
%s </manifest>
|
||||
<spine toc="ncx">
|
||||
%s </spine>
|
||||
</package>`, escapeXML(title), escapeXML(author), slug, items.String(), spine.String())
|
||||
}
|
||||
|
||||
func tocNCX(slug, title string, chapters []epubChapter) string {
|
||||
var points strings.Builder
|
||||
for i, ch := range chapters {
|
||||
chTitle := ch.Title
|
||||
if chTitle == "" {
|
||||
chTitle = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
points.WriteString(fmt.Sprintf(` <navPoint id="np%d" playOrder="%d">
|
||||
<navLabel><text>%s</text></navLabel>
|
||||
<content src="chapter-%04d.xhtml"/>
|
||||
</navPoint>`+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
||||
<head><meta name="dtb:uid" content="%s"/></head>
|
||||
<docTitle><text>%s</text></docTitle>
|
||||
<navMap>
|
||||
%s </navMap>
|
||||
</ncx>`, slug, escapeXML(title), points.String())
|
||||
}
|
||||
|
||||
func chapterXHTML(ch epubChapter) string {
|
||||
title := ch.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", ch.Number)
|
||||
}
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>%s</title><link rel="stylesheet" href="style.css"/></head>
|
||||
<body>
|
||||
<h1 class="chapter-title">%s</h1>
|
||||
%s
|
||||
</body>
|
||||
</html>`, escapeXML(title), escapeXML(title), ch.HTML)
|
||||
}
|
||||
|
||||
func epubCSS() string {
|
||||
return `body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 1em 2em; }
|
||||
h1.chapter-title { font-size: 1.4em; margin-bottom: 1em; }
|
||||
p { margin: 0 0 0.8em 0; text-indent: 1.5em; }
|
||||
p:first-of-type { text-indent: 0; }
|
||||
`
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
return s
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
@@ -708,12 +709,17 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
|
||||
// (same as handleAudioProxy) — the client plays from storage immediately.
|
||||
//
|
||||
// Slow path (first request): streams MP3 audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, any
|
||||
// pending audio_jobs task for this key is marked done. Subsequent requests hit
|
||||
// the fast path and skip TTS generation entirely.
|
||||
// Slow path (first request): streams audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, subsequent
|
||||
// requests hit the fast path and skip TTS generation entirely.
|
||||
//
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
// Query params:
|
||||
//
|
||||
// voice (optional, defaults to DefaultVoice)
|
||||
// format (optional, "mp3" or "wav"; defaults to "mp3")
|
||||
//
|
||||
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
|
||||
// raw WAV frames to the client with lower latency at the cost of larger files.
|
||||
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
@@ -727,7 +733,17 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
format := r.URL.Query().Get("format")
|
||||
if format != "wav" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
|
||||
|
||||
// ── Fast path: already in MinIO ───────────────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
@@ -756,23 +772,51 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the TTS stream.
|
||||
// Open the TTS stream (WAV or MP3 depending on format param).
|
||||
var audioStream io.ReadCloser
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
if format == "wav" {
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
@@ -787,11 +831,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uploadDone <- s.deps.AudioStore.PutAudioStream(
|
||||
context.Background(), // use background — request ctx may cancel after client disconnects
|
||||
audioKey, pr, -1, "audio/mpeg",
|
||||
audioKey, pr, -1, contentType,
|
||||
)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1081,6 +1125,166 @@ func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin Audio ────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
|
||||
// Returns all audio jobs, optionally filtered by slug (?slug=...).
|
||||
// Sorted by started descending.
|
||||
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional slug filter.
|
||||
slugFilter := r.URL.Query().Get("slug")
|
||||
|
||||
type jobRow struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
Status string `json:"status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
rows := make([]jobRow, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if slugFilter != "" && t.Slug != slugFilter {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, jobRow{
|
||||
ID: t.ID,
|
||||
CacheKey: t.CacheKey,
|
||||
Slug: t.Slug,
|
||||
Chapter: t.Chapter,
|
||||
Voice: t.Voice,
|
||||
Status: string(t.Status),
|
||||
WorkerID: t.WorkerID,
|
||||
ErrorMessage: t.ErrorMessage,
|
||||
Started: t.Started.Format(time.RFC3339),
|
||||
Finished: t.Finished.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
|
||||
}
|
||||
|
||||
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
|
||||
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
|
||||
//
|
||||
// Enqueues one audio task per chapter in [from, to].
|
||||
// skip_existing (default true): skip chapters already cached in MinIO — use this
|
||||
// to resume a previously interrupted bulk job.
|
||||
// force: if true, enqueue even when a pending/running task already exists.
|
||||
// Max 1000 chapters per request.
|
||||
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Voice string `json:"voice"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if body.Voice == "" {
|
||||
body.Voice = s.cfg.DefaultVoice
|
||||
}
|
||||
if body.From < 1 || body.To < body.From {
|
||||
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
|
||||
return
|
||||
}
|
||||
if body.To-body.From > 999 {
|
||||
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
|
||||
return
|
||||
}
|
||||
|
||||
// skip_existing defaults to true (resume-friendly).
|
||||
skipExisting := true
|
||||
if body.SkipExisting != nil {
|
||||
skipExisting = *body.SkipExisting
|
||||
}
|
||||
|
||||
var taskIDs []string
|
||||
skipped := 0
|
||||
|
||||
for n := body.From; n <= body.To; n++ {
|
||||
// Skip chapters already cached in MinIO.
|
||||
if skipExisting {
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip chapters with an active (pending/running) task unless force=true.
|
||||
if !body.Force {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
|
||||
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
|
||||
"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
|
||||
return
|
||||
}
|
||||
taskIDs = append(taskIDs, id)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"enqueued": len(taskIDs),
|
||||
"skipped": skipped,
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
|
||||
// Body: {"slug": "..."}
|
||||
// Cancels all pending and running audio tasks for the given slug.
|
||||
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
|
||||
"slug", body.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1152,6 +1356,9 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
key := kokoro.VoiceSampleKey(voice)
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
key = cfai.VoiceSampleKey(voice)
|
||||
}
|
||||
|
||||
// Generate sample on demand when it is not in MinIO yet.
|
||||
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
|
||||
@@ -1161,7 +1368,13 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
||||
mp3 []byte
|
||||
err error
|
||||
)
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
mp3, err = s.deps.CFAI.GenerateAudio(r.Context(), voiceSampleText, voice)
|
||||
} else if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
@@ -1538,6 +1751,109 @@ func stripMarkdown(src string) string {
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// ── EPUB export ───────────────────────────────────────────────────────────────
|
||||
|
||||
// handleExportEPUB handles GET /api/export/{slug}.
|
||||
// Generates and streams an EPUB file for the book identified by slug.
|
||||
// Optional query params: from=N&to=N to limit the chapter range (default: all).
|
||||
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse optional from/to range.
|
||||
fromStr := r.URL.Query().Get("from")
|
||||
toStr := r.URL.Query().Get("to")
|
||||
fromN, toN := 0, 0
|
||||
if fromStr != "" {
|
||||
v, err := strconv.Atoi(fromStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
|
||||
return
|
||||
}
|
||||
fromN = v
|
||||
}
|
||||
if toStr != "" {
|
||||
v, err := strconv.Atoi(toStr)
|
||||
if err != nil || v < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
|
||||
return
|
||||
}
|
||||
toN = v
|
||||
}
|
||||
|
||||
// Fetch book metadata for title and author.
|
||||
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
|
||||
if err != nil || !inLib {
|
||||
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
|
||||
// List all chapters.
|
||||
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
|
||||
return
|
||||
}
|
||||
|
||||
// Filter chapters by from/to range.
|
||||
var filtered []epubChapter
|
||||
for _, ch := range chapters {
|
||||
if fromN > 0 && ch.Number < fromN {
|
||||
continue
|
||||
}
|
||||
if toN > 0 && ch.Number > toN {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch markdown from MinIO.
|
||||
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
|
||||
if readErr != nil {
|
||||
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
|
||||
// Skip chapters that cannot be fetched.
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert markdown to HTML using goldmark.
|
||||
md := goldmark.New()
|
||||
var htmlBuf bytes.Buffer
|
||||
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
|
||||
htmlBuf.Reset()
|
||||
htmlBuf.WriteString("<p>" + mdText + "</p>")
|
||||
}
|
||||
|
||||
filtered = append(filtered, epubChapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
HTML: htmlBuf.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
|
||||
return
|
||||
}
|
||||
|
||||
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/epub+zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(epubBytes)
|
||||
}
|
||||
|
||||
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
|
||||
|
||||
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when
|
||||
|
||||
290
backend/internal/backend/handlers_image.go
Normal file
290
backend/internal/backend/handlers_image.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
)
|
||||
|
||||
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
|
||||
// Returns the list of supported Cloudflare AI image generation models.
|
||||
func (s *Server) handleAdminImageGenModels(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.ImageGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
models := s.deps.ImageGen.Models()
|
||||
writeJSON(w, 0, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// imageGenRequest is the JSON body for POST /api/admin/image-gen.
|
||||
type imageGenRequest struct {
|
||||
// Prompt is the text description of the desired image.
|
||||
Prompt string `json:"prompt"`
|
||||
|
||||
// Model is the CF Workers AI model ID (e.g. "@cf/black-forest-labs/flux-2-dev").
|
||||
// Defaults to the recommended model for the given type.
|
||||
Model string `json:"model"`
|
||||
|
||||
// Type is either "cover" or "chapter".
|
||||
Type string `json:"type"`
|
||||
|
||||
// Slug is the book slug. Required for cover; required for chapter.
|
||||
Slug string `json:"slug"`
|
||||
|
||||
// Chapter number (1-based). Required when type == "chapter".
|
||||
Chapter int `json:"chapter"`
|
||||
|
||||
// ReferenceImageB64 is an optional base64-encoded PNG/JPEG reference image.
|
||||
// When present the img2img path is used.
|
||||
ReferenceImageB64 string `json:"reference_image_b64"`
|
||||
|
||||
// NumSteps overrides inference steps (default 20).
|
||||
NumSteps int `json:"num_steps"`
|
||||
|
||||
// Width / Height override output dimensions (0 = model default).
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
|
||||
// Guidance overrides prompt guidance scale (0 = model default).
|
||||
Guidance float64 `json:"guidance"`
|
||||
|
||||
// Strength for img2img: 0.0–1.0, default 0.75.
|
||||
Strength float64 `json:"strength"`
|
||||
|
||||
// SaveToCover when true stores the result as the book cover in MinIO
|
||||
// (overwriting any existing cover) and sets the book's cover URL.
|
||||
// Only valid when type == "cover".
|
||||
SaveToCover bool `json:"save_to_cover"`
|
||||
}
|
||||
|
||||
// imageGenResponse is the JSON body returned by POST /api/admin/image-gen.
|
||||
type imageGenResponse struct {
|
||||
// ImageB64 is the generated image as a base64-encoded PNG string.
|
||||
ImageB64 string `json:"image_b64"`
|
||||
// ContentType is "image/png" or "image/jpeg".
|
||||
ContentType string `json:"content_type"`
|
||||
// Saved indicates whether the image was persisted to MinIO.
|
||||
Saved bool `json:"saved"`
|
||||
// CoverURL is the URL the cover is now served from (only set when Saved==true).
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
// Bytes is the raw image size in bytes.
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
// handleAdminImageGen handles POST /api/admin/image-gen.
|
||||
//
|
||||
// Generates an image using Cloudflare Workers AI and optionally stores it.
|
||||
// Multipart/form-data is also accepted so the reference image can be uploaded
|
||||
// directly; otherwise the reference is expected as base64 JSON.
|
||||
func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.ImageGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
|
||||
var req imageGenRequest
|
||||
var refImageData []byte
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
// Multipart: parse JSON fields from a "json" part + optional "reference" file part.
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
|
||||
return
|
||||
}
|
||||
if jsonPart := r.FormValue("json"); jsonPart != "" {
|
||||
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if f, _, err := r.FormFile("reference"); err == nil {
|
||||
defer f.Close()
|
||||
refImageData, _ = io.ReadAll(f)
|
||||
}
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.ReferenceImageB64 != "" {
|
||||
var decErr error
|
||||
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
|
||||
if decErr != nil {
|
||||
// Try std without padding
|
||||
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
|
||||
if decErr != nil {
|
||||
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Prompt) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "prompt is required")
|
||||
return
|
||||
}
|
||||
if req.Type != "cover" && req.Type != "chapter" {
|
||||
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
|
||||
return
|
||||
}
|
||||
if req.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if req.Type == "chapter" && req.Chapter <= 0 {
|
||||
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
model := cfai.ImageModel(req.Model)
|
||||
if model == "" {
|
||||
if req.Type == "cover" {
|
||||
model = cfai.DefaultImageModel
|
||||
} else {
|
||||
model = cfai.ImageModelFlux2Klein4B
|
||||
}
|
||||
}
|
||||
|
||||
imgReq := cfai.ImageRequest{
|
||||
Prompt: req.Prompt,
|
||||
Model: model,
|
||||
NumSteps: req.NumSteps,
|
||||
Width: req.Width,
|
||||
Height: req.Height,
|
||||
Guidance: req.Guidance,
|
||||
Strength: req.Strength,
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: image gen requested",
|
||||
"type", req.Type, "slug", req.Slug, "chapter", req.Chapter,
|
||||
"model", model, "has_reference", len(refImageData) > 0)
|
||||
|
||||
var imgData []byte
|
||||
var genErr error
|
||||
if len(refImageData) > 0 {
|
||||
imgData, genErr = s.deps.ImageGen.GenerateImageFromReference(r.Context(), imgReq, refImageData)
|
||||
} else {
|
||||
imgData, genErr = s.deps.ImageGen.GenerateImage(r.Context(), imgReq)
|
||||
}
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: image gen failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "image generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
contentType := sniffImageContentType(imgData)
|
||||
|
||||
// ── Optional persistence ──────────────────────────────────────────────────
|
||||
var saved bool
|
||||
var coverURL string
|
||||
|
||||
if req.SaveToCover && req.Type == "cover" && s.deps.CoverStore != nil {
|
||||
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
|
||||
s.deps.Log.Error("admin: save generated cover failed", "slug", req.Slug, "err", err)
|
||||
// Non-fatal: still return the image
|
||||
} else {
|
||||
saved = true
|
||||
coverURL = fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug)
|
||||
s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData))
|
||||
}
|
||||
}
|
||||
|
||||
// Encode result as base64
|
||||
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||
|
||||
writeJSON(w, 0, imageGenResponse{
|
||||
ImageB64: b64,
|
||||
ContentType: contentType,
|
||||
Saved: saved,
|
||||
CoverURL: coverURL,
|
||||
Model: string(model),
|
||||
Bytes: len(imgData),
|
||||
})
|
||||
}
|
||||
|
||||
// saveCoverRequest is the JSON body for POST /api/admin/image-gen/save-cover.
|
||||
type saveCoverRequest struct {
|
||||
// Slug is the book slug whose cover should be overwritten.
|
||||
Slug string `json:"slug"`
|
||||
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
|
||||
ImageB64 string `json:"image_b64"`
|
||||
}
|
||||
|
||||
// handleAdminImageGenSaveCover handles POST /api/admin/image-gen/save-cover.
|
||||
//
|
||||
// Accepts a pre-generated image as base64 and stores it as the book cover in
|
||||
// MinIO, replacing the existing one. Does not call Cloudflare AI at all.
|
||||
func (s *Server) handleAdminImageGenSaveCover(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.CoverStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req saveCoverRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if req.ImageB64 == "" {
|
||||
jsonError(w, http.StatusBadRequest, "image_b64 is required")
|
||||
return
|
||||
}
|
||||
|
||||
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
|
||||
if err != nil {
|
||||
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
contentType := sniffImageContentType(imgData)
|
||||
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
|
||||
s.deps.Log.Error("admin: save-cover failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "save cover: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: cover saved via image-gen", "slug", req.Slug, "bytes", len(imgData))
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"saved": true,
|
||||
"cover_url": fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug),
|
||||
"bytes": len(imgData),
|
||||
})
|
||||
}
|
||||
|
||||
// sniffImageContentType returns the MIME type of the image bytes.
|
||||
func sniffImageContentType(data []byte) string {
|
||||
if len(data) >= 4 {
|
||||
// PNG: 0x89 P N G
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47 {
|
||||
return "image/png"
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
// WebP: RIFF....WEBP
|
||||
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
|
||||
return "image/webp"
|
||||
}
|
||||
}
|
||||
return "image/png"
|
||||
}
|
||||
424
backend/internal/backend/handlers_textgen.go
Normal file
424
backend/internal/backend/handlers_textgen.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
|
||||
// Returns the list of supported Cloudflare AI text generation models.
|
||||
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
models := s.deps.TextGen.Models()
|
||||
writeJSON(w, 0, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
// ── Chapter names ─────────────────────────────────────────────────────────────
|
||||
|
||||
// textGenChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesRequest struct {
|
||||
// Slug is the book slug whose chapters to process.
|
||||
Slug string `json:"slug"`
|
||||
// Pattern is a free-text description of the desired naming convention,
|
||||
// e.g. "Chapter {n}: {brief scene description}".
|
||||
Pattern string `json:"pattern"`
|
||||
// Model is the CF Workers AI model ID. Defaults to the recommended model when empty.
|
||||
Model string `json:"model"`
|
||||
// MaxTokens limits response length (0 = model default).
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
|
||||
type textGenChapterNamesResponse struct {
|
||||
// Chapters is the list of proposed chapter titles, indexed by chapter number.
|
||||
Chapters []proposedChapterTitle `json:"chapters"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
// RawResponse is the raw model output for debugging / manual editing.
|
||||
RawResponse string `json:"raw_response"`
|
||||
}
|
||||
|
||||
// proposedChapterTitle is a single chapter with its AI-proposed title.
|
||||
type proposedChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
// OldTitle is the current title stored in the database.
|
||||
OldTitle string `json:"old_title"`
|
||||
// NewTitle is the AI-proposed replacement.
|
||||
NewTitle string `json:"new_title"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
|
||||
//
|
||||
// Reads all chapter titles for the given slug, sends them to the LLM with the
|
||||
// requested naming pattern, and returns proposed replacements. Does NOT persist
|
||||
// anything — the frontend shows a diff and the user must confirm via
|
||||
// POST /api/admin/text-gen/chapter-names/apply.
|
||||
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenChapterNamesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Pattern) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Load existing chapter list.
|
||||
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(chapters) == 0 {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
// Build the prompt.
|
||||
var chapterListSB strings.Builder
|
||||
for _, ch := range chapters {
|
||||
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
|
||||
}
|
||||
|
||||
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
|
||||
`The user provides a list of chapter numbers with their current titles, ` +
|
||||
`and a naming pattern template. ` +
|
||||
`Your job: produce one new title for every chapter, following the pattern exactly. ` +
|
||||
`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (2–5 word) scene hint derived from the existing title. ` +
|
||||
`RULES: ` +
|
||||
`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
|
||||
`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
|
||||
`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
|
||||
`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
|
||||
`5. Each element: {"number": <int>, "title": <string>}. ` +
|
||||
`6. Output every chapter in the input list, in order. Do not skip any.`
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Naming pattern: %s\n\nChapters:\n%s",
|
||||
req.Pattern,
|
||||
chapterListSB.String(),
|
||||
)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
// Default to 4096 tokens so large chapter lists are not truncated.
|
||||
maxTokens := req.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 4096
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen chapter-names requested",
|
||||
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
|
||||
|
||||
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the JSON array from the model response.
|
||||
proposed := parseChapterTitlesJSON(raw)
|
||||
|
||||
// Build the response: merge proposed titles with old titles.
|
||||
// Index existing chapters by number for O(1) lookup.
|
||||
existing := make(map[int]string, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
existing[ch.Number] = ch.Title
|
||||
}
|
||||
|
||||
result := make([]proposedChapterTitle, 0, len(proposed))
|
||||
for _, p := range proposed {
|
||||
result = append(result, proposedChapterTitle{
|
||||
Number: p.Number,
|
||||
OldTitle: existing[p.Number],
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenChapterNamesResponse{
|
||||
Chapters: result,
|
||||
Model: string(model),
|
||||
RawResponse: raw,
|
||||
})
|
||||
}
|
||||
|
||||
// parseChapterTitlesJSON extracts the JSON array from a model response.
|
||||
// It tolerates markdown fences and surrounding prose.
|
||||
type rawChapterTitle struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func parseChapterTitlesJSON(raw string) []rawChapterTitle {
|
||||
// Strip markdown fences if present.
|
||||
s := raw
|
||||
if idx := strings.Index(s, "```json"); idx >= 0 {
|
||||
s = s[idx+7:]
|
||||
} else if idx := strings.Index(s, "```"); idx >= 0 {
|
||||
s = s[idx+3:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
// Find the JSON array boundaries.
|
||||
start := strings.Index(s, "[")
|
||||
end := strings.LastIndex(s, "]")
|
||||
if start < 0 || end <= start {
|
||||
return nil
|
||||
}
|
||||
s = s[start : end+1]
|
||||
var out []rawChapterTitle
|
||||
json.Unmarshal([]byte(s), &out) //nolint:errcheck
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Apply chapter names ───────────────────────────────────────────────────────
|
||||
|
||||
// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.
|
||||
type applyChapterNamesRequest struct {
|
||||
// Slug is the book slug to update.
|
||||
Slug string `json:"slug"`
|
||||
// Chapters is the list of chapters to save (number + new_title pairs).
|
||||
// The UI may modify individual titles before confirming.
|
||||
Chapters []applyChapterEntry `json:"chapters"`
|
||||
}
|
||||
|
||||
type applyChapterEntry struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyChapterNames handles POST /api/admin/text-gen/chapter-names/apply.
|
||||
//
|
||||
// Persists the confirmed chapter titles to PocketBase chapters_idx.
|
||||
func (s *Server) handleAdminTextGenApplyChapterNames(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req applyChapterNamesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if len(req.Chapters) == 0 {
|
||||
jsonError(w, http.StatusBadRequest, "chapters is required")
|
||||
return
|
||||
}
|
||||
|
||||
refs := make([]domain.ChapterRef, 0, len(req.Chapters))
|
||||
for _, ch := range req.Chapters {
|
||||
if ch.Number <= 0 {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, domain.ChapterRef{
|
||||
Number: ch.Number,
|
||||
Title: strings.TrimSpace(ch.Title),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.deps.BookWriter.WriteChapterRefs(r.Context(), req.Slug, refs); err != nil {
|
||||
s.deps.Log.Error("admin: apply chapter names failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write chapter refs: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: chapter names applied", "slug", req.Slug, "count", len(refs))
|
||||
writeJSON(w, 0, map[string]any{"updated": len(refs)})
|
||||
}
|
||||
|
||||
// ── Book description ──────────────────────────────────────────────────────────
|
||||
|
||||
// textGenDescriptionRequest is the JSON body for POST /api/admin/text-gen/description.
|
||||
type textGenDescriptionRequest struct {
|
||||
// Slug is the book slug whose description to regenerate.
|
||||
Slug string `json:"slug"`
|
||||
// Instructions is an optional free-text hint for the AI,
|
||||
// e.g. "Write a 3-sentence blurb, avoid spoilers, dramatic tone."
|
||||
Instructions string `json:"instructions"`
|
||||
// Model is the CF Workers AI model ID. Defaults to recommended when empty.
|
||||
Model string `json:"model"`
|
||||
// MaxTokens limits response length (0 = model default).
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// textGenDescriptionResponse is the JSON body returned by POST /api/admin/text-gen/description.
|
||||
type textGenDescriptionResponse struct {
|
||||
// OldDescription is the current summary stored in the database.
|
||||
OldDescription string `json:"old_description"`
|
||||
// NewDescription is the AI-proposed replacement.
|
||||
NewDescription string `json:"new_description"`
|
||||
// Model is the model that was used.
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenDescription handles POST /api/admin/text-gen/description.
|
||||
//
|
||||
// Reads the current book metadata, sends it to the LLM, and returns a proposed
|
||||
// new description. Does NOT persist anything — the user must confirm via
|
||||
// POST /api/admin/text-gen/description/apply.
|
||||
func (s *Server) handleAdminTextGenDescription(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TextGen == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
|
||||
return
|
||||
}
|
||||
|
||||
var req textGenDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Load current book metadata.
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
systemPrompt := `You are a book description writer for a web novel platform. ` +
|
||||
`Given a book's title, author, genres, and current description, write an improved ` +
|
||||
`description that accurately captures the story. ` +
|
||||
`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`
|
||||
|
||||
instructions := strings.TrimSpace(req.Instructions)
|
||||
if instructions == "" {
|
||||
instructions = "Write a compelling 2–4 sentence description. Keep it spoiler-free and engaging."
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf(
|
||||
"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
|
||||
meta.Title,
|
||||
meta.Author,
|
||||
strings.Join(meta.Genres, ", "),
|
||||
meta.Status,
|
||||
meta.Summary,
|
||||
instructions,
|
||||
)
|
||||
|
||||
model := cfai.TextModel(req.Model)
|
||||
if model == "" {
|
||||
model = cfai.DefaultTextModel
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: text-gen description requested",
|
||||
"slug", req.Slug, "model", model)
|
||||
|
||||
newDesc, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
|
||||
Model: model,
|
||||
Messages: []cfai.TextMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
MaxTokens: req.MaxTokens,
|
||||
})
|
||||
if genErr != nil {
|
||||
s.deps.Log.Error("admin: text-gen description failed", "err", genErr)
|
||||
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, textGenDescriptionResponse{
|
||||
OldDescription: meta.Summary,
|
||||
NewDescription: strings.TrimSpace(newDesc),
|
||||
Model: string(model),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Apply description ─────────────────────────────────────────────────────────
|
||||
|
||||
// applyDescriptionRequest is the JSON body for POST /api/admin/text-gen/description/apply.
|
||||
type applyDescriptionRequest struct {
|
||||
// Slug is the book slug to update.
|
||||
Slug string `json:"slug"`
|
||||
// Description is the new summary text to persist.
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// handleAdminTextGenApplyDescription handles POST /api/admin/text-gen/description/apply.
|
||||
//
|
||||
// Updates only the summary field in PocketBase, leaving all other book metadata
|
||||
// unchanged.
|
||||
func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req applyDescriptionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Slug) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Description) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "description is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing metadata so we can write it back with only summary changed.
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
|
||||
return
|
||||
}
|
||||
|
||||
meta.Summary = strings.TrimSpace(req.Description)
|
||||
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("admin: apply description failed", "slug", req.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
|
||||
writeJSON(w, 0, map[string]any{"updated": true})
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
@@ -69,6 +70,18 @@ type Dependencies struct {
|
||||
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
|
||||
// audio generation is done by the runner).
|
||||
PocketTTS pockettts.Client
|
||||
// CFAI is the Cloudflare Workers AI TTS client (used for voice sample
|
||||
// generation and audio-stream live TTS; audio task generation is done by the runner).
|
||||
CFAI cfai.Client
|
||||
// ImageGen is the Cloudflare Workers AI image generation client.
|
||||
// If nil, image generation endpoints return 503.
|
||||
ImageGen cfai.ImageGenClient
|
||||
// TextGen is the Cloudflare Workers AI text generation client.
|
||||
// If nil, text generation endpoints return 503.
|
||||
TextGen cfai.TextGenClient
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -174,6 +187,23 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Admin image generation endpoints
|
||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
|
||||
// Admin text generation endpoints (chapter names + book description)
|
||||
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
@@ -185,6 +215,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
|
||||
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
|
||||
|
||||
// EPUB export
|
||||
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
|
||||
|
||||
// Reading progress
|
||||
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
|
||||
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
|
||||
@@ -330,6 +363,23 @@ func (s *Server) voices(ctx context.Context) []domain.Voice {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cloudflare AI voices ──────────────────────────────────────────────────
|
||||
if s.deps.CFAI != nil {
|
||||
for _, speaker := range cfai.Speakers() {
|
||||
gender := "m"
|
||||
if cfai.IsFemale(speaker) {
|
||||
gender = "f"
|
||||
}
|
||||
result = append(result, domain.Voice{
|
||||
ID: cfai.VoiceID(speaker),
|
||||
Engine: "cfai",
|
||||
Lang: "en",
|
||||
Gender: gender,
|
||||
})
|
||||
}
|
||||
s.deps.Log.Info("backend: loaded CF AI voices", "count", len(cfai.Speakers()))
|
||||
}
|
||||
|
||||
s.voiceMu.Lock()
|
||||
s.cachedVoices = result
|
||||
s.voiceMu.Unlock()
|
||||
|
||||
@@ -80,9 +80,14 @@ type RankingStore interface {
|
||||
|
||||
// AudioStore covers audio object storage (runner writes; backend reads).
|
||||
type AudioStore interface {
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
AudioObjectKey(slug string, n int, voice string) string
|
||||
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
AudioObjectKeyExt(slug string, n int, voice, ext string) string
|
||||
|
||||
// AudioExists returns true when the audio object is present in MinIO.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
@@ -91,7 +96,7 @@ type AudioStore interface {
|
||||
|
||||
// PutAudioStream uploads audio from r to MinIO under key.
|
||||
// size must be the exact byte length of r, or -1 to use multipart upload.
|
||||
// contentType should be "audio/mpeg".
|
||||
// contentType should be "audio/mpeg" or "audio/wav".
|
||||
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
214
backend/internal/cfai/client.go
Normal file
214
backend/internal/cfai/client.go
Normal file
@@ -0,0 +1,214 @@
|
||||
// Package cfai provides a client for Cloudflare Workers AI Text-to-Speech models.
|
||||
//
|
||||
// The Cloudflare Workers AI REST API is used to run TTS models:
|
||||
//
|
||||
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
|
||||
// Authorization: Bearer {apiToken}
|
||||
// Content-Type: application/json
|
||||
// { "text": "...", "speaker": "luna" }
|
||||
//
|
||||
// → 200 audio/mpeg — raw MP3 bytes
|
||||
//
|
||||
// Currently supported model: @cf/deepgram/aura-2-en (40 English speakers).
|
||||
// Voice IDs are prefixed with "cfai:" to distinguish them from Kokoro/pocket-tts
|
||||
// voices (e.g. "cfai:luna", "cfai:orion").
|
||||
//
|
||||
// The API is batch-only (no streaming), so GenerateAudio waits for the full
|
||||
// response. There is no 100-second Cloudflare proxy timeout because we are
|
||||
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
|
||||
// homelab tunnel.
|
||||
package cfai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultModel is the Cloudflare Workers AI TTS model used by default.
|
||||
DefaultModel = "@cf/deepgram/aura-2-en"
|
||||
|
||||
// voicePrefix is the prefix used to namespace CF AI voice IDs.
|
||||
voicePrefix = "cfai:"
|
||||
)
|
||||
|
||||
// aura2Speakers is the exhaustive list of speakers supported by aura-2-en.
|
||||
var aura2Speakers = []string{
|
||||
"amalthea", "andromeda", "apollo", "arcas", "aries", "asteria",
|
||||
"athena", "atlas", "aurora", "callista", "cora", "cordelia",
|
||||
"delia", "draco", "electra", "harmonia", "helena", "hera",
|
||||
"hermes", "hyperion", "iris", "janus", "juno", "jupiter",
|
||||
"luna", "mars", "minerva", "neptune", "odysseus", "ophelia",
|
||||
"orion", "orpheus", "pandora", "phoebe", "pluto", "saturn",
|
||||
"thalia", "theia", "vesta", "zeus",
|
||||
}
|
||||
|
||||
// femaleSpeakers is the set of aura-2-en speaker names that are female voices.
|
||||
var femaleSpeakers = map[string]struct{}{
|
||||
"amalthea": {}, "andromeda": {}, "aries": {}, "asteria": {},
|
||||
"athena": {}, "aurora": {}, "callista": {}, "cora": {},
|
||||
"cordelia": {}, "delia": {}, "electra": {}, "harmonia": {},
|
||||
"helena": {}, "hera": {}, "iris": {}, "juno": {},
|
||||
"luna": {}, "minerva": {}, "ophelia": {}, "pandora": {},
|
||||
"phoebe": {}, "thalia": {}, "theia": {}, "vesta": {},
|
||||
}
|
||||
|
||||
// IsCFAIVoice reports whether voice is served by the Cloudflare AI client.
|
||||
// CF AI voices use the "cfai:" prefix, e.g. "cfai:luna".
|
||||
func IsCFAIVoice(voice string) bool {
|
||||
return strings.HasPrefix(voice, voicePrefix)
|
||||
}
|
||||
|
||||
// SpeakerName strips the "cfai:" prefix and returns the bare speaker name.
|
||||
// If voice is not a CF AI voice the original string is returned unchanged.
|
||||
func SpeakerName(voice string) string {
|
||||
return strings.TrimPrefix(voice, voicePrefix)
|
||||
}
|
||||
|
||||
// VoiceID returns the full voice ID (with prefix) for a bare speaker name.
|
||||
func VoiceID(speaker string) string {
|
||||
return voicePrefix + speaker
|
||||
}
|
||||
|
||||
// VoiceSampleKey returns the MinIO object key for a CF AI voice sample MP3.
|
||||
func VoiceSampleKey(voice string) string {
|
||||
safe := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '_'
|
||||
}, voice)
|
||||
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
|
||||
}
|
||||
|
||||
// IsFemale reports whether the given CF AI voice ID (with or without prefix)
|
||||
// is a female speaker.
|
||||
func IsFemale(voice string) bool {
|
||||
speaker := SpeakerName(voice)
|
||||
_, ok := femaleSpeakers[speaker]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Speakers returns all available bare speaker names for aura-2-en.
|
||||
func Speakers() []string {
|
||||
out := make([]string, len(aura2Speakers))
|
||||
copy(out, aura2Speakers)
|
||||
return out
|
||||
}
|
||||
|
||||
// Client is the interface for interacting with Cloudflare Workers AI TTS.
|
||||
type Client interface {
|
||||
// GenerateAudio synthesises text using the given voice (e.g. "cfai:luna")
|
||||
// and returns raw MP3 bytes.
|
||||
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
|
||||
|
||||
// StreamAudioMP3 is not natively supported by the CF AI batch API.
|
||||
// It buffers the full response and returns an io.ReadCloser over the bytes,
|
||||
// so callers can use it like a stream without special-casing.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV is not natively supported; the CF AI model returns MP3.
|
||||
// This method returns the same MP3 bytes wrapped as an io.ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns all available voice IDs (with the "cfai:" prefix).
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// httpClient is the concrete CF AI HTTP client.
|
||||
type httpClient struct {
|
||||
accountID string
|
||||
apiToken string
|
||||
model string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New returns a Client for the given Cloudflare account and API token.
|
||||
// model defaults to DefaultModel when empty.
|
||||
func New(accountID, apiToken, model string) Client {
|
||||
if model == "" {
|
||||
model = DefaultModel
|
||||
}
|
||||
return &httpClient{
|
||||
accountID: accountID,
|
||||
apiToken: apiToken,
|
||||
model: model,
|
||||
http: &http.Client{Timeout: 5 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
|
||||
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("cfai: empty text")
|
||||
}
|
||||
speaker := SpeakerName(voice)
|
||||
if speaker == "" {
|
||||
speaker = "luna"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"text": text,
|
||||
"speaker": speaker,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai: marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
|
||||
c.accountID, c.model)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("cfai: server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
mp3, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai: read response: %w", err)
|
||||
}
|
||||
return mp3, nil
|
||||
}
|
||||
|
||||
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
|
||||
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
mp3, err := c.GenerateAudio(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(mp3)), nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV generates audio (MP3) and wraps it as an io.ReadCloser.
|
||||
// Note: the CF AI aura-2-en model returns MP3 regardless of the method name.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
return c.StreamAudioMP3(ctx, text, voice)
|
||||
}
|
||||
|
||||
// ListVoices returns all available CF AI voice IDs (with the "cfai:" prefix).
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
ids := make([]string, len(aura2Speakers))
|
||||
for i, s := range aura2Speakers {
|
||||
ids[i] = VoiceID(s)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
312
backend/internal/cfai/image.go
Normal file
312
backend/internal/cfai/image.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Image generation via Cloudflare Workers AI text-to-image models.
|
||||
//
|
||||
// API reference:
|
||||
//
|
||||
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
|
||||
// Authorization: Bearer {apiToken}
|
||||
// Content-Type: application/json
|
||||
//
|
||||
// Text-only request (all models):
|
||||
//
|
||||
// { "prompt": "...", "num_steps": 20 }
|
||||
//
|
||||
// Reference-image request:
|
||||
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
|
||||
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
|
||||
//
|
||||
// All models return raw PNG bytes on success (Content-Type: image/png).
|
||||
//
|
||||
// Recommended models for LibNovel:
|
||||
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
|
||||
// - Chapter images (speed): flux-2-klein-4b, flux-1-schnell
|
||||
// - With reference image: flux-2-dev, flux-2-klein-9b, sd-v1-5-img2img
|
||||
package cfai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg" // register JPEG decoder
|
||||
_ "image/png" // register PNG decoder
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImageModel identifies a Cloudflare Workers AI text-to-image model.
|
||||
type ImageModel string
|
||||
|
||||
const (
|
||||
// ImageModelFlux2Dev — best quality, multi-reference. Recommended for covers.
|
||||
ImageModelFlux2Dev ImageModel = "@cf/black-forest-labs/flux-2-dev"
|
||||
// ImageModelFlux2Klein9B — 9B params, multi-reference. Good for covers.
|
||||
ImageModelFlux2Klein9B ImageModel = "@cf/black-forest-labs/flux-2-klein-9b"
|
||||
// ImageModelFlux2Klein4B — ultra-fast, unified gen+edit. Recommended for chapters.
|
||||
ImageModelFlux2Klein4B ImageModel = "@cf/black-forest-labs/flux-2-klein-4b"
|
||||
// ImageModelFlux1Schnell — fastest, text-only. Good for quick illustrations.
|
||||
ImageModelFlux1Schnell ImageModel = "@cf/black-forest-labs/flux-1-schnell"
|
||||
// ImageModelSDXLLightning — fast 1024px generation.
|
||||
ImageModelSDXLLightning ImageModel = "@cf/bytedance/stable-diffusion-xl-lightning"
|
||||
// ImageModelSD15Img2Img — explicit img2img with flat RGBA reference.
|
||||
ImageModelSD15Img2Img ImageModel = "@cf/runwayml/stable-diffusion-v1-5-img2img"
|
||||
// ImageModelSDXLBase — Stability AI SDXL base.
|
||||
ImageModelSDXLBase ImageModel = "@cf/stabilityai/stable-diffusion-xl-base-1.0"
|
||||
// ImageModelLucidOrigin — Leonardo AI; strong prompt adherence.
|
||||
ImageModelLucidOrigin ImageModel = "@cf/leonardo/lucid-origin"
|
||||
// ImageModelPhoenix10 — Leonardo AI; accurate text rendering.
|
||||
ImageModelPhoenix10 ImageModel = "@cf/leonardo/phoenix-1.0"
|
||||
|
||||
// DefaultImageModel is the default model for book-cover generation.
|
||||
DefaultImageModel = ImageModelFlux2Dev
|
||||
)
|
||||
|
||||
// ImageModelInfo describes a single image generation model.
|
||||
type ImageModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Provider string `json:"provider"`
|
||||
SupportsRef bool `json:"supports_ref"`
|
||||
RecommendedFor []string `json:"recommended_for"` // "cover" and/or "chapter"
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// AllImageModels returns metadata about every supported image model.
|
||||
func AllImageModels() []ImageModelInfo {
|
||||
return []ImageModelInfo{
|
||||
{
|
||||
ID: string(ImageModelFlux2Dev), Label: "FLUX.2 Dev", Provider: "Black Forest Labs",
|
||||
SupportsRef: true, RecommendedFor: []string{"cover"},
|
||||
Description: "Best quality; multi-reference editing. Recommended for book covers.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelFlux2Klein9B), Label: "FLUX.2 Klein 9B", Provider: "Black Forest Labs",
|
||||
SupportsRef: true, RecommendedFor: []string{"cover"},
|
||||
Description: "9B parameters with multi-reference support.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelFlux2Klein4B), Label: "FLUX.2 Klein 4B", Provider: "Black Forest Labs",
|
||||
SupportsRef: true, RecommendedFor: []string{"chapter"},
|
||||
Description: "Ultra-fast unified gen+edit. Recommended for chapter images.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelFlux1Schnell), Label: "FLUX.1 Schnell", Provider: "Black Forest Labs",
|
||||
SupportsRef: false, RecommendedFor: []string{"chapter"},
|
||||
Description: "Fastest inference. Good for quick chapter illustrations.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelSDXLLightning), Label: "SDXL Lightning", Provider: "ByteDance",
|
||||
SupportsRef: false, RecommendedFor: []string{"chapter"},
|
||||
Description: "Lightning-fast 1024px images in a few steps.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelSD15Img2Img), Label: "SD 1.5 img2img", Provider: "RunwayML",
|
||||
SupportsRef: true, RecommendedFor: []string{"cover", "chapter"},
|
||||
Description: "Explicit img2img: generates from a reference image + prompt.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelSDXLBase), Label: "SDXL Base 1.0", Provider: "Stability AI",
|
||||
SupportsRef: false, RecommendedFor: []string{"cover"},
|
||||
Description: "Stable Diffusion XL base model.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelLucidOrigin), Label: "Lucid Origin", Provider: "Leonardo AI",
|
||||
SupportsRef: false, RecommendedFor: []string{"cover"},
|
||||
Description: "Highly prompt-responsive; strong graphic design and HD renders.",
|
||||
},
|
||||
{
|
||||
ID: string(ImageModelPhoenix10), Label: "Phoenix 1.0", Provider: "Leonardo AI",
|
||||
SupportsRef: false, RecommendedFor: []string{"cover"},
|
||||
Description: "Exceptional prompt adherence; accurate text rendering.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ImageRequest is the input to GenerateImage / GenerateImageFromReference.
|
||||
type ImageRequest struct {
|
||||
// Prompt is the text description of the desired image.
|
||||
Prompt string
|
||||
// Model is the CF Workers AI model. Defaults to DefaultImageModel when empty.
|
||||
Model ImageModel
|
||||
// NumSteps controls inference quality (default 20). Range: 1–20.
|
||||
NumSteps int
|
||||
// Width and Height in pixels. 0 = model default (typically 1024x1024).
|
||||
Width, Height int
|
||||
// Guidance controls prompt adherence (default 7.5).
|
||||
Guidance float64
|
||||
// Strength for img2img: 0.0 = copy reference, 1.0 = ignore reference (default 0.75).
|
||||
Strength float64
|
||||
}
|
||||
|
||||
// ImageGenClient generates images via Cloudflare Workers AI.
|
||||
type ImageGenClient interface {
|
||||
// GenerateImage creates an image from a text prompt only.
|
||||
// Returns raw PNG bytes.
|
||||
GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error)
|
||||
|
||||
// GenerateImageFromReference creates an image from a text prompt + reference image.
|
||||
// refImage should be PNG or JPEG bytes. Returns raw PNG bytes.
|
||||
GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error)
|
||||
|
||||
// Models returns metadata about all supported image models.
|
||||
Models() []ImageModelInfo
|
||||
}
|
||||
|
||||
// imageGenHTTPClient is the concrete CF AI image generation client.
|
||||
type imageGenHTTPClient struct {
|
||||
accountID string
|
||||
apiToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewImageGen returns an ImageGenClient for the given Cloudflare account.
|
||||
func NewImageGen(accountID, apiToken string) ImageGenClient {
|
||||
return &imageGenHTTPClient{
|
||||
accountID: accountID,
|
||||
apiToken: apiToken,
|
||||
http: &http.Client{Timeout: 5 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateImage generates an image from text only.
|
||||
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
|
||||
req = applyImageDefaults(req)
|
||||
body := map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"num_steps": req.NumSteps,
|
||||
}
|
||||
if req.Width > 0 {
|
||||
body["width"] = req.Width
|
||||
}
|
||||
if req.Height > 0 {
|
||||
body["height"] = req.Height
|
||||
}
|
||||
if req.Guidance > 0 {
|
||||
body["guidance"] = req.Guidance
|
||||
}
|
||||
return c.callImageAPI(ctx, req.Model, body)
|
||||
}
|
||||
|
||||
// GenerateImageFromReference generates an image from a text prompt + reference image.
|
||||
func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) {
|
||||
if len(refImage) == 0 {
|
||||
return c.GenerateImage(ctx, req)
|
||||
}
|
||||
req = applyImageDefaults(req)
|
||||
|
||||
var body map[string]any
|
||||
if req.Model == ImageModelSD15Img2Img {
|
||||
pixels, err := decodeImageToRGBA(refImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai/image: decode reference: %w", err)
|
||||
}
|
||||
strength := req.Strength
|
||||
if strength <= 0 {
|
||||
strength = 0.75
|
||||
}
|
||||
body = map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"image": pixels,
|
||||
"strength": strength,
|
||||
"num_steps": req.NumSteps,
|
||||
}
|
||||
} else {
|
||||
b64 := base64.StdEncoding.EncodeToString(refImage)
|
||||
body = map[string]any{
|
||||
"prompt": req.Prompt,
|
||||
"image_b64": b64,
|
||||
"num_steps": req.NumSteps,
|
||||
}
|
||||
if req.Strength > 0 {
|
||||
body["strength"] = req.Strength
|
||||
}
|
||||
}
|
||||
if req.Width > 0 {
|
||||
body["width"] = req.Width
|
||||
}
|
||||
if req.Height > 0 {
|
||||
body["height"] = req.Height
|
||||
}
|
||||
if req.Guidance > 0 {
|
||||
body["guidance"] = req.Guidance
|
||||
}
|
||||
return c.callImageAPI(ctx, req.Model, body)
|
||||
}
|
||||
|
||||
// Models returns all supported image model metadata.
|
||||
func (c *imageGenHTTPClient) Models() []ImageModelInfo {
|
||||
return AllImageModels()
|
||||
}
|
||||
|
||||
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
|
||||
}
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
|
||||
c.accountID, string(model))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai/image: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai/image: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
msg := string(errBody)
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cfai/image: read response: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func applyImageDefaults(req ImageRequest) ImageRequest {
|
||||
if req.Model == "" {
|
||||
req.Model = DefaultImageModel
|
||||
}
|
||||
if req.NumSteps <= 0 {
|
||||
req.NumSteps = 20
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array
|
||||
// required by the stable-diffusion-v1-5-img2img model.
|
||||
func decodeImageToRGBA(data []byte) ([]uint8, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
w := bounds.Max.X - bounds.Min.X
|
||||
h := bounds.Max.Y - bounds.Min.Y
|
||||
pixels := make([]uint8, w*h*4)
|
||||
idx := 0
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
r, g, b, a := img.At(x, y).RGBA()
|
||||
pixels[idx] = uint8(r >> 8)
|
||||
pixels[idx+1] = uint8(g >> 8)
|
||||
pixels[idx+2] = uint8(b >> 8)
|
||||
pixels[idx+3] = uint8(a >> 8)
|
||||
idx += 4
|
||||
}
|
||||
}
|
||||
return pixels, nil
|
||||
}
|
||||
239
backend/internal/cfai/text.go
Normal file
239
backend/internal/cfai/text.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Text generation via Cloudflare Workers AI LLM models.
|
||||
//
|
||||
// API reference:
|
||||
//
|
||||
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
|
||||
// Authorization: Bearer {apiToken}
|
||||
// Content-Type: application/json
|
||||
//
|
||||
// Request body (all models):
|
||||
//
|
||||
// { "messages": [{"role":"system","content":"..."},{"role":"user","content":"..."}] }
|
||||
//
|
||||
// Response (wrapped):
|
||||
//
|
||||
// { "result": { "response": "..." }, "success": true }
|
||||
package cfai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TextModel identifies a Cloudflare Workers AI text generation model.
|
||||
type TextModel string
|
||||
|
||||
const (
|
||||
// TextModelGemma4 — Google Gemma 4, 256k context.
|
||||
TextModelGemma4 TextModel = "@cf/google/gemma-4-26b-a4b-it"
|
||||
// TextModelLlama4Scout — Meta Llama 4 Scout 17B, multimodal.
|
||||
TextModelLlama4Scout TextModel = "@cf/meta/llama-4-scout-17b-16e-instruct"
|
||||
// TextModelLlama33_70B — Meta Llama 3.3 70B, fast fp8.
|
||||
TextModelLlama33_70B TextModel = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
||||
// TextModelQwen3_30B — Qwen3 30B MoE, function calling.
|
||||
TextModelQwen3_30B TextModel = "@cf/qwen/qwen3-30b-a3b-fp8"
|
||||
// TextModelMistralSmall — Mistral Small 3.1 24B, 128k context.
|
||||
TextModelMistralSmall TextModel = "@cf/mistralai/mistral-small-3.1-24b-instruct"
|
||||
// TextModelQwQ32B — Qwen QwQ 32B reasoning model.
|
||||
TextModelQwQ32B TextModel = "@cf/qwen/qwq-32b"
|
||||
// TextModelDeepSeekR1 — DeepSeek R1 distill Qwen 32B.
|
||||
TextModelDeepSeekR1 TextModel = "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b"
|
||||
// TextModelGemma3_12B — Google Gemma 3 12B, 80k context.
|
||||
TextModelGemma3_12B TextModel = "@cf/google/gemma-3-12b-it"
|
||||
// TextModelGPTOSS120B — OpenAI gpt-oss-120b, high reasoning.
|
||||
TextModelGPTOSS120B TextModel = "@cf/openai/gpt-oss-120b"
|
||||
// TextModelGPTOSS20B — OpenAI gpt-oss-20b, lower latency.
|
||||
TextModelGPTOSS20B TextModel = "@cf/openai/gpt-oss-20b"
|
||||
// TextModelNemotron3 — NVIDIA Nemotron 3 120B, agentic.
|
||||
TextModelNemotron3 TextModel = "@cf/nvidia/nemotron-3-120b-a12b"
|
||||
// TextModelLlama32_3B — Meta Llama 3.2 3B, lightweight.
|
||||
TextModelLlama32_3B TextModel = "@cf/meta/llama-3.2-3b-instruct"
|
||||
|
||||
// DefaultTextModel is the default model used when none is specified.
|
||||
DefaultTextModel = TextModelLlama4Scout
|
||||
)
|
||||
|
||||
// TextModelInfo describes a single text generation model.
|
||||
type TextModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Provider string `json:"provider"`
|
||||
ContextSize int `json:"context_size"` // max context in tokens
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// AllTextModels returns metadata about every supported text generation model.
|
||||
func AllTextModels() []TextModelInfo {
|
||||
return []TextModelInfo{
|
||||
{
|
||||
ID: string(TextModelGemma4), Label: "Gemma 4 26B", Provider: "Google",
|
||||
ContextSize: 256000,
|
||||
Description: "Google's most intelligent open model family. 256k context, function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama4Scout), Label: "Llama 4 Scout 17B", Provider: "Meta",
|
||||
ContextSize: 131000,
|
||||
Description: "Natively multimodal, 16 experts. Good all-purpose model with function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama33_70B), Label: "Llama 3.3 70B (fp8 fast)", Provider: "Meta",
|
||||
ContextSize: 24000,
|
||||
Description: "Llama 3.3 70B quantized to fp8 for speed. Excellent instruction following.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelQwen3_30B), Label: "Qwen3 30B MoE", Provider: "Qwen",
|
||||
ContextSize: 32768,
|
||||
Description: "MoE architecture with strong reasoning and instruction following.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelMistralSmall), Label: "Mistral Small 3.1 24B", Provider: "MistralAI",
|
||||
ContextSize: 128000,
|
||||
Description: "Strong text performance with 128k context and function calling.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelQwQ32B), Label: "QwQ 32B (reasoning)", Provider: "Qwen",
|
||||
ContextSize: 24000,
|
||||
Description: "Reasoning model — thinks before answering. Slower but more accurate.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelDeepSeekR1), Label: "DeepSeek R1 32B", Provider: "DeepSeek",
|
||||
ContextSize: 80000,
|
||||
Description: "R1-distilled reasoning model. Outperforms o1-mini on many benchmarks.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGemma3_12B), Label: "Gemma 3 12B", Provider: "Google",
|
||||
ContextSize: 80000,
|
||||
Description: "Multimodal, 128k context, multilingual (140+ languages).",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGPTOSS120B), Label: "GPT-OSS 120B", Provider: "OpenAI",
|
||||
ContextSize: 128000,
|
||||
Description: "OpenAI open-weight model for production, general purpose, high reasoning.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelGPTOSS20B), Label: "GPT-OSS 20B", Provider: "OpenAI",
|
||||
ContextSize: 128000,
|
||||
Description: "OpenAI open-weight model for lower latency and specialized use cases.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelNemotron3), Label: "Nemotron 3 120B", Provider: "NVIDIA",
|
||||
ContextSize: 256000,
|
||||
Description: "Hybrid MoE with leading accuracy for multi-agent applications.",
|
||||
},
|
||||
{
|
||||
ID: string(TextModelLlama32_3B), Label: "Llama 3.2 3B", Provider: "Meta",
|
||||
ContextSize: 80000,
|
||||
Description: "Lightweight model for simple tasks. Fast and cheap.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TextMessage is a single message in a chat conversation.
|
||||
type TextMessage struct {
|
||||
Role string `json:"role"` // "system" or "user"
|
||||
Content string `json:"content"` // message text
|
||||
}
|
||||
|
||||
// TextRequest is the input to Generate.
|
||||
type TextRequest struct {
|
||||
// Model is the CF Workers AI model ID. Defaults to DefaultTextModel when empty.
|
||||
Model TextModel
|
||||
// Messages is the conversation history (system + user messages).
|
||||
Messages []TextMessage
|
||||
// MaxTokens limits the output length (0 = model default).
|
||||
MaxTokens int
|
||||
}
|
||||
|
||||
// TextGenClient generates text via Cloudflare Workers AI LLM models.
|
||||
type TextGenClient interface {
|
||||
// Generate sends a chat-style request and returns the model's response text.
|
||||
Generate(ctx context.Context, req TextRequest) (string, error)
|
||||
|
||||
// Models returns metadata about all supported text generation models.
|
||||
Models() []TextModelInfo
|
||||
}
|
||||
|
||||
// textGenHTTPClient is the concrete CF AI text generation client.
|
||||
type textGenHTTPClient struct {
|
||||
accountID string
|
||||
apiToken string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewTextGen returns a TextGenClient for the given Cloudflare account.
|
||||
func NewTextGen(accountID, apiToken string) TextGenClient {
|
||||
return &textGenHTTPClient{
|
||||
accountID: accountID,
|
||||
apiToken: apiToken,
|
||||
http: &http.Client{Timeout: 5 * time.Minute},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sends messages to the model and returns the response text.
|
||||
func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (string, error) {
|
||||
if req.Model == "" {
|
||||
req.Model = DefaultTextModel
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"messages": req.Messages,
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
body["max_tokens"] = req.MaxTokens
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: marshal: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
|
||||
c.accountID, string(req.Model))
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cfai/text: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
msg := string(errBody)
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return "", fmt.Errorf("cfai/text: model %s returned %d: %s", req.Model, resp.StatusCode, msg)
|
||||
}
|
||||
|
||||
// CF AI wraps responses: { "result": { "response": "..." }, "success": true }
|
||||
var wrapper struct {
|
||||
Result struct {
|
||||
Response string `json:"response"`
|
||||
} `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
|
||||
return "", fmt.Errorf("cfai/text: decode response: %w", err)
|
||||
}
|
||||
if !wrapper.Success {
|
||||
return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors)
|
||||
}
|
||||
return wrapper.Result.Response, nil
|
||||
}
|
||||
|
||||
// Models returns all supported text generation model metadata.
|
||||
func (c *textGenHTTPClient) Models() []TextModelInfo {
|
||||
return AllTextModels()
|
||||
}
|
||||
@@ -66,6 +66,18 @@ type PocketTTS struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// CFAI holds credentials for Cloudflare Workers AI TTS.
|
||||
type CFAI struct {
|
||||
// AccountID is the Cloudflare account ID.
|
||||
// An empty string disables CF AI generation.
|
||||
AccountID string
|
||||
// APIToken is a Workers AI API token with Workers AI Read+Edit permissions.
|
||||
APIToken string
|
||||
// Model is the Workers AI TTS model ID.
|
||||
// Defaults to "@cf/deepgram/aura-2-en" when empty.
|
||||
Model 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
|
||||
@@ -153,6 +165,7 @@ type Config struct {
|
||||
MinIO MinIO
|
||||
Kokoro Kokoro
|
||||
PocketTTS PocketTTS
|
||||
CFAI CFAI
|
||||
LibreTranslate LibreTranslate
|
||||
HTTP HTTP
|
||||
Runner Runner
|
||||
@@ -203,6 +216,17 @@ func Load() Config {
|
||||
URL: envOr("POCKET_TTS_URL", ""),
|
||||
},
|
||||
|
||||
CFAI: CFAI{
|
||||
AccountID: envOr("CFAI_ACCOUNT_ID", ""),
|
||||
APIToken: envOr("CFAI_API_TOKEN", ""),
|
||||
Model: envOr("CFAI_TTS_MODEL", ""),
|
||||
},
|
||||
|
||||
LibreTranslate: LibreTranslate{
|
||||
URL: envOr("LIBRETRANSLATE_URL", ""),
|
||||
APIKey: envOr("LIBRETRANSLATE_API_KEY", ""),
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,11 @@ type Client interface {
|
||||
// waiting for the full output. The caller must always close the ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
|
||||
// response_format:"wav". The caller must always close the ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
||||
// on error — callers should treat an empty list as "service unavailable".
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
@@ -167,6 +172,47 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
|
||||
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "af_bella"
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(map[string]any{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
"speed": 1.0,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal wav stream request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build wav stream request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: wav stream request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("kokoro: wav stream returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
||||
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
|
||||
@@ -59,6 +59,12 @@ type Client interface {
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// raw WAV audio directly from pocket-tts without any transcoding.
|
||||
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -160,6 +166,25 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
|
||||
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
|
||||
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("pockettts: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
resp, err := c.postTTS(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
|
||||
@@ -78,7 +78,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
// 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)
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
|
||||
defer heartbeatTick.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/cfai"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/libretranslate"
|
||||
@@ -112,6 +113,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
|
||||
// CFAI is the Cloudflare Workers AI TTS client (cfai:* prefixed voices).
|
||||
// If nil, CF AI voice tasks will fail with a clear error.
|
||||
CFAI cfai.Client
|
||||
// LibreTranslate is the machine translation client.
|
||||
// If nil, translation tasks will fail with a clear error.
|
||||
LibreTranslate libretranslate.Client
|
||||
@@ -555,6 +559,18 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
return
|
||||
}
|
||||
log.Info("runner: audio generated via pocket-tts", "voice", task.Voice)
|
||||
} else if cfai.IsCFAIVoice(task.Voice) {
|
||||
if r.deps.CFAI == nil {
|
||||
fail("cloudflare AI client not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN empty)")
|
||||
return
|
||||
}
|
||||
var genErr error
|
||||
audioData, genErr = r.deps.CFAI.GenerateAudio(ctx, text, task.Voice)
|
||||
if genErr != nil {
|
||||
fail(fmt.Sprintf("cfai generate: %v", genErr))
|
||||
return
|
||||
}
|
||||
log.Info("runner: audio generated via cloudflare AI", "voice", task.Voice)
|
||||
} else {
|
||||
if r.deps.Kokoro == nil {
|
||||
fail("kokoro client not configured (KOKORO_URL is empty)")
|
||||
|
||||
@@ -126,6 +126,9 @@ type stubAudioStore struct {
|
||||
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
|
||||
}
|
||||
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
||||
}
|
||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
@@ -199,6 +202,14 @@ func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
s.called.Add(1)
|
||||
if s.genErr != nil {
|
||||
return nil, s.genErr
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return []string{"af_bella"}, nil
|
||||
}
|
||||
|
||||
@@ -109,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
|
||||
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
// Format: {slug}/{n}/{voice}.{ext}
|
||||
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
|
||||
return AudioObjectKeyExt(slug, n, voice, "mp3")
|
||||
}
|
||||
|
||||
// AvatarObjectKey returns the MinIO object key for a user avatar image.
|
||||
|
||||
@@ -26,6 +26,11 @@ import (
|
||||
// ErrNotFound is returned by single-record lookups when no record exists.
|
||||
var ErrNotFound = errors.New("storage: record not found")
|
||||
|
||||
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
|
||||
// hung PocketBase never stalls the backend/runner process indefinitely.
|
||||
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
|
||||
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// pbClient is the internal PocketBase REST admin client.
|
||||
type pbClient struct {
|
||||
baseURL string
|
||||
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pb auth: %w", err)
|
||||
}
|
||||
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := pbHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
|
||||
}
|
||||
|
||||
@@ -74,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
"rating": meta.Rating,
|
||||
}
|
||||
// Upsert via filter: if exists PATCH, otherwise POST.
|
||||
// Use a conflict-retry pattern to handle concurrent scrapes racing to insert
|
||||
// the same slug: if POST fails (or another concurrent writer beat us to it),
|
||||
// re-fetch and PATCH instead.
|
||||
existing, err := s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
// POST failed — a concurrent writer may have inserted the same slug.
|
||||
// Re-fetch and fall through to PATCH.
|
||||
existing, err = s.getBookBySlug(ctx, meta.Slug)
|
||||
if err != nil {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
}
|
||||
@@ -376,6 +388,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return AudioObjectKeyExt(slug, n, voice, ext)
|
||||
}
|
||||
|
||||
func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||
}
|
||||
@@ -574,6 +590,28 @@ func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
|
||||
}
|
||||
cancelled := 0
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if patchErr := s.pb.patch(ctx,
|
||||
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
}
|
||||
return cancelled, nil
|
||||
}
|
||||
|
||||
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
|
||||
|
||||
@@ -36,6 +36,10 @@ type Producer interface {
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
|
||||
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
|
||||
// Returns the number of tasks cancelled.
|
||||
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// Consumer is the read/claim side of the task queue used by the runner.
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
|
||||
BIN
backend/runner
BIN
backend/runner
Binary file not shown.
@@ -7,3 +7,4 @@ RUN xcaddy build \
|
||||
|
||||
FROM caddy:2-alpine
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY errors/ /srv/errors/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 — Page Not Found — LibNovel</title>
|
||||
<title>404 — Page Not Found — libnovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +27,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -114,7 +113,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
203
caddy/errors/500.html
Normal file
203
caddy/errors/500.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>500 — Internal Error — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #a1a1aa;
|
||||
border: 1px solid #27272a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Book with lightning bolt SVG -->
|
||||
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<!-- Book cover -->
|
||||
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
|
||||
<!-- Spine -->
|
||||
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
|
||||
<!-- Pages edge -->
|
||||
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
|
||||
<!-- Lightning bolt -->
|
||||
<path d="M44 22 L34 46 H42 L36 70 L58 42 H48 L56 22 Z" fill="#f59e0b" opacity="0.9"/>
|
||||
<!-- Text lines -->
|
||||
<rect x="22" y="58" width="28" height="2.5" rx="1.25" fill="#3f3f46"/>
|
||||
<rect x="22" y="63" width="18" height="2.5" rx="1.25" fill="#3f3f46"/>
|
||||
<rect x="22" y="68" width="24" height="2.5" rx="1.25" fill="#3f3f46"/>
|
||||
</svg>
|
||||
|
||||
<div class="watermark">500</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Internal error</span>
|
||||
</div>
|
||||
|
||||
<h1>Something went wrong</h1>
|
||||
<p>An unexpected error occurred on our end. We're on it — try again in a moment.</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn" href="/">Go home</a>
|
||||
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
|
||||
<p class="refresh-note">Auto-refreshing in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>502 — Service Unavailable — LibNovel</title>
|
||||
<title>502 — Service Unavailable — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +28,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -126,7 +126,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Under Maintenance — LibNovel</title>
|
||||
<title>Under Maintenance — libnovel</title>
|
||||
<meta http-equiv="refresh" content="30">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -28,11 +29,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
/* ── Main ── */
|
||||
main {
|
||||
@@ -129,7 +129,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>504 — Gateway Timeout — LibNovel</title>
|
||||
<title>504 — Gateway Timeout — libnovel</title>
|
||||
<meta http-equiv="refresh" content="20">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -27,11 +28,10 @@
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
color: #f59e0b;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
@@ -126,7 +126,7 @@
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
<a class="logo" href="/">libnovel</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
@@ -19,6 +19,9 @@ x-infra-env: &infra-env
|
||||
MEILI_API_KEY: "${MEILI_MASTER_KEY}"
|
||||
# Valkey
|
||||
VALKEY_ADDR: "valkey:6379"
|
||||
# Cloudflare AI (TTS + image generation)
|
||||
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
|
||||
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
|
||||
|
||||
services:
|
||||
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
|
||||
@@ -126,6 +129,26 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Redis (Asynq task queue — accessed locally by backend, remotely by homelab runner) ──
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--requirepass "${REDIS_PASSWORD}"
|
||||
# No public port — backend reaches it via internal network.
|
||||
# Homelab runner reaches it via Caddy TLS proxy on :6380 → redis:6379.
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── Backend API ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
image: kalekber/libnovel-backend:${GIT_TAG:-latest}
|
||||
@@ -151,6 +174,8 @@ services:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# No public port — all traffic is routed via Caddy.
|
||||
expose:
|
||||
- "8080"
|
||||
@@ -164,10 +189,9 @@ services:
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "backend"
|
||||
# Asynq task queue — backend enqueues jobs to homelab Redis via Caddy TLS proxy.
|
||||
# Set to "rediss://:password@redis.libnovel.cc:6380" in Doppler prd config.
|
||||
# Leave empty to fall back to PocketBase polling.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
|
||||
# Homelab runner connects to the same Redis via Caddy TLS proxy on :6380.
|
||||
REDIS_ADDR: "redis:6379"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "http://localhost:8080/health"]
|
||||
@@ -269,6 +293,7 @@ services:
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
AUTH_SECRET: "${AUTH_SECRET}"
|
||||
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
# Valkey
|
||||
VALKEY_ADDR: "valkey:6379"
|
||||
@@ -382,12 +407,10 @@ services:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
- "6380:6380" # Redis TCP proxy (TLS) for homelab → Asynq
|
||||
- "6380:6380" # Redis TCP proxy (TLS) for homelab runner → Asynq
|
||||
environment:
|
||||
DOMAIN: "${DOMAIN}"
|
||||
CADDY_ACME_EMAIL: "${CADDY_ACME_EMAIL}"
|
||||
# Homelab Redis address — Caddy TCP-proxies inbound :6380 to this.
|
||||
HOMELAB_REDIS_ADDR: "${HOMELAB_REDIS_ADDR:?HOMELAB_REDIS_ADDR required for Redis TCP proxy}"
|
||||
env_file:
|
||||
- path: ./crowdsec/.crowdsec.env
|
||||
required: false
|
||||
@@ -421,6 +444,7 @@ volumes:
|
||||
pb_data:
|
||||
meili_data:
|
||||
valkey_data:
|
||||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
|
||||
@@ -58,6 +58,14 @@ services:
|
||||
VALKEY_ADDR: ""
|
||||
GODEBUG: "preferIPv4=1"
|
||||
|
||||
# ── LibreTranslate (internal Docker network) ──────────────────────────
|
||||
LIBRETRANSLATE_URL: "http://libretranslate:5000"
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis ─────────────────────────────────────────────────────
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
KOKORO_URL: "http://kokoro-fastapi:8880"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
|
||||
@@ -67,6 +75,7 @@ services:
|
||||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
|
||||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||
@@ -280,6 +289,48 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Redis (Asynq task queue) ────────────────────────────────────────────────
|
||||
# Dedicated Redis instance for Asynq job dispatch.
|
||||
# The prod backend enqueues jobs via redis.libnovel.cc:6380 (Caddy TLS proxy →
|
||||
# host:6379). The runner reads from this instance directly on the Docker network.
|
||||
# Port is bound to 0.0.0.0:6379 so the Caddy layer4 proxy on prod can reach it.
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── LibreTranslate ──────────────────────────────────────────────────────────
|
||||
# Self-hosted machine translation. Runner connects via http://libretranslate:5000.
|
||||
# Only English → configured target languages are loaded to save RAM.
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LT_API_KEYS: "true"
|
||||
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
|
||||
LT_LOAD_ONLY: "en,ru,id,pt,fr"
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
expose:
|
||||
- "5000"
|
||||
volumes:
|
||||
- libretranslate_data:/app/db
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:5000/languages"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
# ── Valkey ──────────────────────────────────────────────────────────────────
|
||||
# Used by GlitchTip for task queuing.
|
||||
valkey:
|
||||
@@ -460,6 +511,8 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
libretranslate_data:
|
||||
valkey_data:
|
||||
uptime_kuma_data:
|
||||
gotify_data:
|
||||
|
||||
@@ -11,25 +11,15 @@
|
||||
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
# - Redis service for Asynq task queue (local to homelab, exposed to prod via Caddy TCP proxy)
|
||||
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
|
||||
# - LibreTranslate service for machine translation (internal network only)
|
||||
#
|
||||
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
|
||||
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
|
||||
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
|
||||
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: >
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--requirepass "${REDIS_PASSWORD}"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
@@ -43,22 +33,19 @@ services:
|
||||
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
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
- libretranslate
|
||||
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
|
||||
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
|
||||
# directly to Caddy on prod; TLS and SNI still work normally.
|
||||
extra_hosts:
|
||||
- "storage.libnovel.cc:165.22.70.138"
|
||||
- "pb.libnovel.cc:165.22.70.138"
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
@@ -87,13 +74,18 @@ services:
|
||||
# ── Pocket TTS ──────────────────────────────────────────────────────────
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
|
||||
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
|
||||
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
|
||||
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
|
||||
|
||||
# ── 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"
|
||||
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
|
||||
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
|
||||
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
# ── Runner tuning ───────────────────────────────────────────────────────
|
||||
@@ -117,6 +109,5 @@ services:
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
libretranslate_models:
|
||||
libretranslate_db:
|
||||
|
||||
7
justfile
7
justfile
@@ -122,6 +122,13 @@ secrets-env:
|
||||
secrets-dashboard:
|
||||
doppler open dashboard
|
||||
|
||||
# ── Developer setup ───────────────────────────────────────────────────────────
|
||||
|
||||
# One-time dev setup: configure git to use committed hooks in .githooks/
|
||||
setup:
|
||||
git config core.hooksPath .githooks
|
||||
@echo "Git hooks configured (.githooks/pre-commit active)."
|
||||
|
||||
# ── Gitea CI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Validate workflow files
|
||||
|
||||
@@ -190,14 +190,15 @@ create "app_users" '{
|
||||
{"name":"oauth_id", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_sessions" '{
|
||||
create "user_sessions" '{
|
||||
"name":"user_sessions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_agent","type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"created_at","type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id", "type":"text","required":true},
|
||||
{"name":"user_agent", "type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"device_fingerprint", "type":"text"},
|
||||
{"name":"created_at", "type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_library" '{
|
||||
@@ -259,6 +260,22 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "discovery_votes" '{
|
||||
"name":"discovery_votes","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"action", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "book_ratings" '{
|
||||
"name":"book_ratings","type":"base","fields":[
|
||||
{"name":"session_id","type":"text", "required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"rating", "type":"number", "required":true}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
@@ -274,5 +291,7 @@ add_field "app_users" "oauth_provider" "text"
|
||||
add_field "app_users" "oauth_id" "text"
|
||||
add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
add_field "user_library" "shelf" "text"
|
||||
add_field "user_sessions" "device_fingerprint" "text"
|
||||
|
||||
log "done"
|
||||
|
||||
3
ui/.gitignore
vendored
3
ui/.gitignore
vendored
@@ -22,5 +22,4 @@ Thumbs.db
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Generated by CI at build time — do not commit
|
||||
/static/releases.json
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"nav_library": "Library",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feed": "Feed",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profile",
|
||||
@@ -301,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Queuing\u2026",
|
||||
"book_detail_scrape_range": "Scrape range",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Book Cover",
|
||||
"book_detail_admin_chapter_cover": "Chapter Cover",
|
||||
"book_detail_admin_chapter_n": "Chapter #",
|
||||
"book_detail_admin_description": "Description",
|
||||
"book_detail_admin_chapter_names": "Chapter Names",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Voice",
|
||||
"book_detail_admin_generate": "Generate",
|
||||
"book_detail_admin_save_cover": "Save Cover",
|
||||
"book_detail_admin_saving": "Saving…",
|
||||
"book_detail_admin_saved": "Saved",
|
||||
"book_detail_admin_apply": "Apply",
|
||||
"book_detail_admin_applying": "Applying…",
|
||||
"book_detail_admin_applied": "Applied",
|
||||
"book_detail_admin_discard": "Discard",
|
||||
"book_detail_admin_enqueue_audio": "Enqueue Audio",
|
||||
"book_detail_admin_cancel_audio": "Cancel",
|
||||
"book_detail_admin_enqueued": "Enqueued {enqueued}, skipped {skipped}",
|
||||
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
|
||||
"book_detail_scraping_home": "\u2190 Home",
|
||||
"book_detail_rescrape_book": "Rescrape book",
|
||||
@@ -357,6 +376,19 @@
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Tools",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Translation",
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
"admin_nav_logs": "Logs",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Push",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
@@ -409,5 +441,16 @@
|
||||
"profile_text_size_sm": "Small",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Large",
|
||||
"profile_text_size_xl": "X-Large"
|
||||
"profile_text_size_xl": "X-Large",
|
||||
|
||||
"feed_page_title": "Feed — LibNovel",
|
||||
"feed_heading": "Following Feed",
|
||||
"feed_subheading": "Books your followed users are reading",
|
||||
"feed_empty_heading": "Nothing here yet",
|
||||
"feed_empty_body": "Follow other readers to see what they're reading.",
|
||||
"feed_not_logged_in": "Sign in to see your feed.",
|
||||
"feed_reader_label": "reading",
|
||||
"feed_chapters_label": "{n} chapters",
|
||||
"feed_browse_cta": "Browse catalogue",
|
||||
"feed_find_users_cta": "Discover readers"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"nav_library": "Bibliothèque",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feed": "Fil",
|
||||
"nav_feedback": "Retour",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
@@ -301,6 +302,24 @@
|
||||
"book_detail_range_queuing": "En file d'attente…",
|
||||
"book_detail_scrape_range": "Plage d'extraction",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Couverture du livre",
|
||||
"book_detail_admin_chapter_cover": "Couverture du chapitre",
|
||||
"book_detail_admin_chapter_n": "Chapitre n°",
|
||||
"book_detail_admin_description": "Description",
|
||||
"book_detail_admin_chapter_names": "Noms des chapitres",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Voix",
|
||||
"book_detail_admin_generate": "Générer",
|
||||
"book_detail_admin_save_cover": "Enregistrer la couverture",
|
||||
"book_detail_admin_saving": "Enregistrement…",
|
||||
"book_detail_admin_saved": "Enregistré",
|
||||
"book_detail_admin_apply": "Appliquer",
|
||||
"book_detail_admin_applying": "Application…",
|
||||
"book_detail_admin_applied": "Appliqué",
|
||||
"book_detail_admin_discard": "Ignorer",
|
||||
"book_detail_admin_enqueue_audio": "Mettre en file audio",
|
||||
"book_detail_admin_cancel_audio": "Annuler",
|
||||
"book_detail_admin_enqueued": "{enqueued} en file, {skipped} ignorés",
|
||||
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
|
||||
"book_detail_scraping_home": "← Accueil",
|
||||
"book_detail_rescrape_book": "Réextraire le livre",
|
||||
@@ -357,6 +376,18 @@
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Outils",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Traduction",
|
||||
"admin_nav_changelog": "Modifications",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Retours",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
"admin_nav_uptime": "Disponibilité",
|
||||
"admin_nav_push": "Notifications",
|
||||
|
||||
"admin_scrape_status_idle": "Inactif",
|
||||
"admin_scrape_full_catalogue": "Catalogue complet",
|
||||
@@ -408,5 +439,17 @@
|
||||
"profile_text_size_sm": "Petit",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grand",
|
||||
"profile_text_size_xl": "Très grand"
|
||||
"profile_text_size_xl": "Très grand",
|
||||
|
||||
"feed_page_title": "Fil — LibNovel",
|
||||
"feed_heading": "Fil d'abonnements",
|
||||
"feed_subheading": "Livres lus par vos abonnements",
|
||||
"feed_empty_heading": "Rien encore",
|
||||
"feed_empty_body": "Suivez d'autres lecteurs pour voir ce qu'ils lisent.",
|
||||
"feed_not_logged_in": "Connectez-vous pour voir votre fil.",
|
||||
"feed_reader_label": "lit",
|
||||
"feed_chapters_label": "{n} chapitres",
|
||||
"feed_browse_cta": "Parcourir le catalogue",
|
||||
"feed_find_users_cta": "Trouver des lecteurs",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"nav_library": "Perpustakaan",
|
||||
"nav_catalogue": "Katalog",
|
||||
"nav_feed": "Umpan",
|
||||
"nav_feedback": "Masukan",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
@@ -301,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Mengantri…",
|
||||
"book_detail_scrape_range": "Rentang scrape",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Sampul Buku",
|
||||
"book_detail_admin_chapter_cover": "Sampul Bab",
|
||||
"book_detail_admin_chapter_n": "Bab #",
|
||||
"book_detail_admin_description": "Deskripsi",
|
||||
"book_detail_admin_chapter_names": "Nama Bab",
|
||||
"book_detail_admin_audio_tts": "Audio TTS",
|
||||
"book_detail_admin_voice": "Suara",
|
||||
"book_detail_admin_generate": "Buat",
|
||||
"book_detail_admin_save_cover": "Simpan Sampul",
|
||||
"book_detail_admin_saving": "Menyimpan…",
|
||||
"book_detail_admin_saved": "Tersimpan",
|
||||
"book_detail_admin_apply": "Terapkan",
|
||||
"book_detail_admin_applying": "Menerapkan…",
|
||||
"book_detail_admin_applied": "Diterapkan",
|
||||
"book_detail_admin_discard": "Buang",
|
||||
"book_detail_admin_enqueue_audio": "Antre Audio",
|
||||
"book_detail_admin_cancel_audio": "Batal",
|
||||
"book_detail_admin_enqueued": "Diantre {enqueued}, dilewati {skipped}",
|
||||
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
|
||||
"book_detail_scraping_home": "← Beranda",
|
||||
"book_detail_rescrape_book": "Scrape ulang buku",
|
||||
@@ -357,6 +376,18 @@
|
||||
|
||||
"admin_pages_label": "Halaman",
|
||||
"admin_tools_label": "Alat",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Terjemahan",
|
||||
"admin_nav_changelog": "Perubahan",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Masukan",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notifikasi",
|
||||
|
||||
"admin_scrape_status_idle": "Menunggu",
|
||||
"admin_scrape_full_catalogue": "Katalog penuh",
|
||||
@@ -408,5 +439,17 @@
|
||||
"profile_text_size_sm": "Kecil",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Besar",
|
||||
"profile_text_size_xl": "Sangat Besar"
|
||||
"profile_text_size_xl": "Sangat Besar",
|
||||
|
||||
"feed_page_title": "Umpan — LibNovel",
|
||||
"feed_heading": "Umpan Ikutan",
|
||||
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
|
||||
"feed_empty_heading": "Belum ada apa-apa",
|
||||
"feed_empty_body": "Ikuti pembaca lain untuk melihat apa yang mereka baca.",
|
||||
"feed_not_logged_in": "Masuk untuk melihat umpan Anda.",
|
||||
"feed_reader_label": "membaca",
|
||||
"feed_chapters_label": "{n} bab",
|
||||
"feed_browse_cta": "Jelajahi katalog",
|
||||
"feed_find_users_cta": "Temukan pembaca",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"nav_library": "Biblioteca",
|
||||
"nav_catalogue": "Catálogo",
|
||||
"nav_feed": "Feed",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Perfil",
|
||||
@@ -301,6 +302,24 @@
|
||||
"book_detail_range_queuing": "Na fila…",
|
||||
"book_detail_scrape_range": "Intervalo de extração",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_admin_book_cover": "Capa do Livro",
|
||||
"book_detail_admin_chapter_cover": "Capa do Capítulo",
|
||||
"book_detail_admin_chapter_n": "Capítulo nº",
|
||||
"book_detail_admin_description": "Descrição",
|
||||
"book_detail_admin_chapter_names": "Nomes dos Capítulos",
|
||||
"book_detail_admin_audio_tts": "Áudio TTS",
|
||||
"book_detail_admin_voice": "Voz",
|
||||
"book_detail_admin_generate": "Gerar",
|
||||
"book_detail_admin_save_cover": "Salvar Capa",
|
||||
"book_detail_admin_saving": "Salvando…",
|
||||
"book_detail_admin_saved": "Salvo",
|
||||
"book_detail_admin_apply": "Aplicar",
|
||||
"book_detail_admin_applying": "Aplicando…",
|
||||
"book_detail_admin_applied": "Aplicado",
|
||||
"book_detail_admin_discard": "Descartar",
|
||||
"book_detail_admin_enqueue_audio": "Enfileirar Áudio",
|
||||
"book_detail_admin_cancel_audio": "Cancelar",
|
||||
"book_detail_admin_enqueued": "{enqueued} enfileirados, {skipped} ignorados",
|
||||
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
|
||||
"book_detail_scraping_home": "← Início",
|
||||
"book_detail_rescrape_book": "Reextrair livro",
|
||||
@@ -357,6 +376,18 @@
|
||||
|
||||
"admin_pages_label": "Páginas",
|
||||
"admin_tools_label": "Ferramentas",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Áudio",
|
||||
"admin_nav_translation": "Tradução",
|
||||
"admin_nav_changelog": "Alterações",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notificações",
|
||||
|
||||
"admin_scrape_status_idle": "Ocioso",
|
||||
"admin_scrape_full_catalogue": "Catálogo completo",
|
||||
@@ -408,5 +439,17 @@
|
||||
"profile_text_size_sm": "Pequeno",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grande",
|
||||
"profile_text_size_xl": "Muito grande"
|
||||
"profile_text_size_xl": "Muito grande",
|
||||
|
||||
"feed_page_title": "Feed — LibNovel",
|
||||
"feed_heading": "Feed de seguidos",
|
||||
"feed_subheading": "Livros que seus seguidos estão lendo",
|
||||
"feed_empty_heading": "Nada aqui ainda",
|
||||
"feed_empty_body": "Siga outros leitores para ver o que estão lendo.",
|
||||
"feed_not_logged_in": "Faça login para ver seu feed.",
|
||||
"feed_reader_label": "lendo",
|
||||
"feed_chapters_label": "{n} capítulos",
|
||||
"feed_browse_cta": "Ver catálogo",
|
||||
"feed_find_users_cta": "Encontrar leitores",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
"nav_library": "Библиотека",
|
||||
"nav_catalogue": "Каталог",
|
||||
"nav_feed": "Лента",
|
||||
"nav_feedback": "Обратная связь",
|
||||
"nav_admin": "Админ",
|
||||
"nav_profile": "Профиль",
|
||||
@@ -301,6 +302,24 @@
|
||||
"book_detail_range_queuing": "В очереди…",
|
||||
"book_detail_scrape_range": "Диапазон глав",
|
||||
"book_detail_admin": "Администрирование",
|
||||
"book_detail_admin_book_cover": "Обложка книги",
|
||||
"book_detail_admin_chapter_cover": "Обложка главы",
|
||||
"book_detail_admin_chapter_n": "Глава №",
|
||||
"book_detail_admin_description": "Описание",
|
||||
"book_detail_admin_chapter_names": "Названия глав",
|
||||
"book_detail_admin_audio_tts": "Аудио TTS",
|
||||
"book_detail_admin_voice": "Голос",
|
||||
"book_detail_admin_generate": "Сгенерировать",
|
||||
"book_detail_admin_save_cover": "Сохранить обложку",
|
||||
"book_detail_admin_saving": "Сохранение…",
|
||||
"book_detail_admin_saved": "Сохранено",
|
||||
"book_detail_admin_apply": "Применить",
|
||||
"book_detail_admin_applying": "Применение…",
|
||||
"book_detail_admin_applied": "Применено",
|
||||
"book_detail_admin_discard": "Отменить",
|
||||
"book_detail_admin_enqueue_audio": "Поставить в очередь",
|
||||
"book_detail_admin_cancel_audio": "Отмена",
|
||||
"book_detail_admin_enqueued": "В очереди {enqueued}, пропущено {skipped}",
|
||||
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
|
||||
"book_detail_scraping_home": "← На главную",
|
||||
"book_detail_rescrape_book": "Перепарсить книгу",
|
||||
@@ -357,6 +376,18 @@
|
||||
|
||||
"admin_pages_label": "Страницы",
|
||||
"admin_tools_label": "Инструменты",
|
||||
"admin_nav_scrape": "Скрейпинг",
|
||||
"admin_nav_audio": "Аудио",
|
||||
"admin_nav_translation": "Перевод",
|
||||
"admin_nav_changelog": "Изменения",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_feedback": "Отзывы",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
"admin_nav_uptime": "Мониторинг",
|
||||
"admin_nav_push": "Уведомления",
|
||||
|
||||
"admin_scrape_status_idle": "Ожидание",
|
||||
"admin_scrape_full_catalogue": "Полный каталог",
|
||||
@@ -408,5 +439,17 @@
|
||||
"profile_text_size_sm": "Маленький",
|
||||
"profile_text_size_md": "Нормальный",
|
||||
"profile_text_size_lg": "Большой",
|
||||
"profile_text_size_xl": "Очень большой"
|
||||
"profile_text_size_xl": "Очень большой",
|
||||
|
||||
"feed_page_title": "Лента — LibNovel",
|
||||
"feed_heading": "Лента подписок",
|
||||
"feed_subheading": "Книги, которые читают ваши подписки",
|
||||
"feed_empty_heading": "Пока ничего нет",
|
||||
"feed_empty_body": "Подпишитесь на других читателей, чтобы видеть, что они читают.",
|
||||
"feed_not_logged_in": "Войдите, чтобы видеть свою ленту.",
|
||||
"feed_reader_label": "читает",
|
||||
"feed_chapters_label": "{n} глав",
|
||||
"feed_browse_cta": "Каталог",
|
||||
"feed_find_users_cta": "Найти читателей",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
}
|
||||
|
||||
@@ -106,12 +106,14 @@ html {
|
||||
:root {
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-size: 1.05rem;
|
||||
--reading-line-height: 1.85;
|
||||
--reading-max-width: 72ch;
|
||||
}
|
||||
|
||||
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
||||
.prose-chapter {
|
||||
max-width: 72ch;
|
||||
line-height: 1.85;
|
||||
max-width: var(--reading-max-width, 72ch);
|
||||
line-height: var(--reading-line-height, 1.85);
|
||||
font-family: var(--reading-font);
|
||||
font-size: var(--reading-size);
|
||||
color: var(--color-muted);
|
||||
@@ -134,6 +136,12 @@ html {
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
|
||||
/* Indented paragraph style — book-like, no gap, indent instead */
|
||||
.prose-chapter.para-indented p {
|
||||
text-indent: 2em;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.prose-chapter em {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -147,6 +155,40 @@ html {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ── Reading progress bar ───────────────────────────────────────────── */
|
||||
.reading-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
z-index: 100;
|
||||
background: var(--color-brand);
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
/* ── Paginated reader ───────────────────────────────────────────────── */
|
||||
.paginated-container {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.paginated-container .prose-chapter {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE / Edge legacy */
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
209
ui/src/error.html
Normal file
209
ui/src/error.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>%sveltekit.status% — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Inline SVG book illustration */
|
||||
.illustration {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #a1a1aa;
|
||||
border: 1px solid #27272a;
|
||||
}
|
||||
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Book with broken spine SVG -->
|
||||
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<!-- Book cover -->
|
||||
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
|
||||
<!-- Spine -->
|
||||
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
|
||||
<!-- Pages edge -->
|
||||
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
|
||||
<!-- Crack / broken lines -->
|
||||
<path d="M22 38 L38 34 L34 48 L50 44" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<!-- Text lines (faded) -->
|
||||
<rect x="22" y="24" width="28" height="3" rx="1.5" fill="#3f3f46"/>
|
||||
<rect x="22" y="30" width="22" height="3" rx="1.5" fill="#3f3f46"/>
|
||||
<rect x="22" y="56" width="28" height="3" rx="1.5" fill="#3f3f46"/>
|
||||
<rect x="22" y="62" width="18" height="3" rx="1.5" fill="#3f3f46"/>
|
||||
<rect x="22" y="68" width="24" height="3" rx="1.5" fill="#3f3f46"/>
|
||||
<!-- Exclamation dot -->
|
||||
<circle cx="72" cy="22" r="10" fill="#18181b" stroke="#f59e0b" stroke-width="1.5"/>
|
||||
<rect x="71" y="16" width="2" height="8" rx="1" fill="#f59e0b"/>
|
||||
<rect x="71" y="26" width="2" height="2" rx="1" fill="#f59e0b"/>
|
||||
</svg>
|
||||
|
||||
<div class="watermark">%sveltekit.status%</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Something went wrong</span>
|
||||
</div>
|
||||
|
||||
<h1>The page couldn't load</h1>
|
||||
<p>An unexpected error occurred. We're looking into it — try again in a moment.</p>
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn" href="/">Go home</a>
|
||||
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
|
||||
<p class="refresh-note">Auto-refreshing in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -75,6 +75,13 @@ class AudioStore {
|
||||
*/
|
||||
seekRequest = $state<number | null>(null);
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||||
sleepUntil = $state(0);
|
||||
|
||||
/** When true, pause after the current chapter ends instead of navigating. */
|
||||
sleepAfterChapter = $state(false);
|
||||
|
||||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* When true, navigates to the next chapter when the current one ends
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
|
||||
playerStyle?: 'standard' | 'compact';
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -80,12 +82,14 @@
|
||||
nextChapter = null,
|
||||
chapters = [],
|
||||
voices = [],
|
||||
onProRequired = undefined
|
||||
onProRequired = undefined,
|
||||
playerStyle = 'standard'
|
||||
}: Props = $props();
|
||||
|
||||
// ── Derived: voices grouped by engine ──────────────────────────────────
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
// ── Voice selector state ────────────────────────────────────────────────
|
||||
let showVoicePanel = $state(false);
|
||||
@@ -98,6 +102,7 @@
|
||||
* Human-readable label for a voice.
|
||||
* Kokoro: "af_bella" → "Bella (US F)"
|
||||
* Pocket-TTS: "alba" → "Alba (EN F)"
|
||||
* CF AI: "cfai:luna" → "Luna (EN F)"
|
||||
* Falls back gracefully if called with a bare string (e.g. from the store default).
|
||||
*/
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
@@ -110,6 +115,14 @@
|
||||
return kokoroLabelFromId(v);
|
||||
}
|
||||
|
||||
if (v.engine === 'cfai') {
|
||||
// "cfai:luna" → "Luna (EN F)"
|
||||
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
||||
const name = speaker.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const genderLabel = v.gender.toUpperCase();
|
||||
return `${name} (EN ${genderLabel})`;
|
||||
}
|
||||
|
||||
if (v.engine === 'pocket-tts') {
|
||||
const langLabel = v.lang.toUpperCase().replace('-', '');
|
||||
const genderLabel = v.gender.toUpperCase();
|
||||
@@ -554,7 +567,35 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
|
||||
// use the streaming endpoint — audio starts playing within seconds while
|
||||
// generation runs and MinIO is populated concurrently.
|
||||
// Skip when enqueued=true to avoid double-generation with the async runner.
|
||||
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// CF AI (batch-only) or already enqueued by presign: keep the traditional
|
||||
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
|
||||
audioStore.status = 'generating';
|
||||
startProgress();
|
||||
|
||||
@@ -681,6 +722,71 @@
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Sleep timer ────────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let _tick = $state(0);
|
||||
$effect(() => {
|
||||
if (!audioStore.sleepUntil) return;
|
||||
const id = setInterval(() => { _tick++; }, 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
_tick; // subscribe to tick updates
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
// Currently: no timer active at all
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
return;
|
||||
}
|
||||
// Currently: end-of-chapter mode — move to 15m
|
||||
if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
return;
|
||||
}
|
||||
// Currently: timed mode — cycle to next or turn off
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return '';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ── Compact player helpers ─────────────────────────────────────────────────
|
||||
const playPct = $derived(
|
||||
audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
|
||||
);
|
||||
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
function cycleSpeed() {
|
||||
const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]);
|
||||
audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
|
||||
}
|
||||
|
||||
function seekFromCompactBar(e: MouseEvent) {
|
||||
if (audioStore.duration <= 0) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
audioStore.seekRequest = pct * audioStore.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -731,6 +837,121 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if playerStyle === 'compact'}
|
||||
<!-- ── Compact player ──────────────────────────────────────────────────────── -->
|
||||
<div class="mt-4 p-3 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3.5 h-3.5 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>
|
||||
{m.player_loading()}
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-xs text-(--color-muted)">
|
||||
<span>{m.reader_generating_narration()}</span>
|
||||
<span class="tabular-nums">{Math.round(audioStore.progress)}%</span>
|
||||
</div>
|
||||
<div class="w-full h-1 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {audioStore.progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<div class="space-y-2">
|
||||
<!-- Seekable progress bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
|
||||
onclick={seekFromCompactBar}
|
||||
>
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||
</div>
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Time display -->
|
||||
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
<!-- Speed cycle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-medium text-(--color-muted) hover:text-(--color-text) flex-shrink-0 tabular-nums transition-colors"
|
||||
title="Playback speed"
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if audioStore.active}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
{m.reader_load_this_chapter()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -797,6 +1018,16 @@
|
||||
{@render voiceRow(v)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Cloudflare AI section -->
|
||||
{#if cfaiVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
|
||||
</div>
|
||||
{#each cfaiVoices as v (v.id)}
|
||||
{@render voiceRow(v)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
@@ -887,6 +1118,30 @@
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepAfterChapter
|
||||
? 'Stop after this chapter'
|
||||
: audioStore.sleepUntil
|
||||
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
|
||||
: 'Sleep timer off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{#if audioStore.sleepAfterChapter}
|
||||
End Ch.
|
||||
{:else if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
@@ -935,3 +1190,4 @@
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
57
ui/src/lib/components/StarRating.svelte
Normal file
57
ui/src/lib/components/StarRating.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rating: number; // current user rating 0–5 (0 = unrated)
|
||||
avg?: number; // average rating
|
||||
count?: number; // total ratings
|
||||
readonly?: boolean; // display-only mode
|
||||
size?: 'sm' | 'md';
|
||||
onrate?: (r: number) => void;
|
||||
}
|
||||
|
||||
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
|
||||
|
||||
let hovered = $state(0);
|
||||
|
||||
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
onmouseenter={() => { if (!readonly) hovered = star; }}
|
||||
onmouseleave={() => { if (!readonly) hovered = 0; }}
|
||||
onclick={() => { if (!readonly) onrate?.(star); }}
|
||||
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
|
||||
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
|
||||
>
|
||||
<svg
|
||||
class="{starSize} transition-colors"
|
||||
fill={star <= display ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button[disabled] { pointer-events: none; }
|
||||
svg { color: #f59e0b; }
|
||||
</style>
|
||||
@@ -1,2 +1,4 @@
|
||||
/* eslint-disable */
|
||||
export * from './messages/_index.js'
|
||||
export * from './messages/_index.js'
|
||||
// enabling auto-import by exposing all messages as m
|
||||
export * as m from './messages/_index.js'
|
||||
@@ -2,6 +2,7 @@
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
export * from './nav_library.js'
|
||||
export * from './nav_catalogue.js'
|
||||
export * from './nav_feed.js'
|
||||
export * from './nav_feedback.js'
|
||||
export * from './nav_admin.js'
|
||||
export * from './nav_profile.js'
|
||||
@@ -278,6 +279,24 @@ export * from './book_detail_to_chapter.js'
|
||||
export * from './book_detail_range_queuing.js'
|
||||
export * from './book_detail_scrape_range.js'
|
||||
export * from './book_detail_admin.js'
|
||||
export * from './book_detail_admin_book_cover.js'
|
||||
export * from './book_detail_admin_chapter_cover.js'
|
||||
export * from './book_detail_admin_chapter_n.js'
|
||||
export * from './book_detail_admin_description.js'
|
||||
export * from './book_detail_admin_chapter_names.js'
|
||||
export * from './book_detail_admin_audio_tts.js'
|
||||
export * from './book_detail_admin_voice.js'
|
||||
export * from './book_detail_admin_generate.js'
|
||||
export * from './book_detail_admin_save_cover.js'
|
||||
export * from './book_detail_admin_saving.js'
|
||||
export * from './book_detail_admin_saved.js'
|
||||
export * from './book_detail_admin_apply.js'
|
||||
export * from './book_detail_admin_applying.js'
|
||||
export * from './book_detail_admin_applied.js'
|
||||
export * from './book_detail_admin_discard.js'
|
||||
export * from './book_detail_admin_enqueue_audio.js'
|
||||
export * from './book_detail_admin_cancel_audio.js'
|
||||
export * from './book_detail_admin_enqueued.js'
|
||||
export * from './book_detail_scraping_progress.js'
|
||||
export * from './book_detail_scraping_home.js'
|
||||
export * from './book_detail_rescrape_book.js'
|
||||
@@ -328,6 +347,19 @@ export * from './user_following_label.js'
|
||||
export * from './user_no_books.js'
|
||||
export * from './admin_pages_label.js'
|
||||
export * from './admin_tools_label.js'
|
||||
export * from './admin_nav_scrape.js'
|
||||
export * from './admin_nav_audio.js'
|
||||
export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
export * from './admin_nav_logs.js'
|
||||
export * from './admin_nav_uptime.js'
|
||||
export * from './admin_nav_push.js'
|
||||
export * from './admin_nav_gitea.js'
|
||||
export * from './admin_scrape_status_idle.js'
|
||||
export * from './admin_scrape_full_catalogue.js'
|
||||
export * from './admin_scrape_single_book.js'
|
||||
@@ -373,4 +405,14 @@ export * from './profile_text_size.js'
|
||||
export * from './profile_text_size_sm.js'
|
||||
export * from './profile_text_size_md.js'
|
||||
export * from './profile_text_size_lg.js'
|
||||
export * from './profile_text_size_xl.js'
|
||||
export * from './profile_text_size_xl.js'
|
||||
export * from './feed_page_title.js'
|
||||
export * from './feed_heading.js'
|
||||
export * from './feed_subheading.js'
|
||||
export * from './feed_empty_heading.js'
|
||||
export * from './feed_empty_body.js'
|
||||
export * from './feed_not_logged_in.js'
|
||||
export * from './feed_reader_label.js'
|
||||
export * from './feed_chapters_label.js'
|
||||
export * from './feed_browse_cta.js'
|
||||
export * from './feed_find_users_cta.js'
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_analytics.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_analytics.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_AnalyticsInputs */
|
||||
|
||||
const en_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Analytics`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Аналитика`)
|
||||
};
|
||||
|
||||
const id_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Analitik`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Análise`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Analytique`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Analytics" |
|
||||
*
|
||||
* @param {Admin_Nav_AnalyticsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AnalyticsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_analytics(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_analytics(inputs)
|
||||
if (locale === "id") return id_admin_nav_analytics(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_analytics(inputs)
|
||||
return fr_admin_nav_analytics(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_audio.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_audio.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_AudioInputs */
|
||||
|
||||
const en_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Аудио`)
|
||||
};
|
||||
|
||||
const id_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Áudio`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Audio" |
|
||||
*
|
||||
* @param {Admin_Nav_AudioInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_audio(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_audio(inputs)
|
||||
if (locale === "id") return id_admin_nav_audio(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_audio(inputs)
|
||||
return fr_admin_nav_audio(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_changelog.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_changelog.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ChangelogInputs */
|
||||
|
||||
const en_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Changelog`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Изменения`)
|
||||
};
|
||||
|
||||
const id_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Perubahan`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Alterações`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Modifications`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Changelog" |
|
||||
*
|
||||
* @param {Admin_Nav_ChangelogInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ChangelogInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_changelog(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_changelog(inputs)
|
||||
if (locale === "id") return id_admin_nav_changelog(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_changelog(inputs)
|
||||
return fr_admin_nav_changelog(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_errors.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_errors.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ErrorsInputs */
|
||||
|
||||
const en_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Errors`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Ошибки`)
|
||||
};
|
||||
|
||||
const id_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Kesalahan`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Erros`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Erreurs`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Errors" |
|
||||
*
|
||||
* @param {Admin_Nav_ErrorsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ErrorsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_errors(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_errors(inputs)
|
||||
if (locale === "id") return id_admin_nav_errors(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_errors(inputs)
|
||||
return fr_admin_nav_errors(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_feedback.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_feedback.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_FeedbackInputs */
|
||||
|
||||
const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feedback`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отзывы`)
|
||||
};
|
||||
|
||||
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Masukan`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feedback`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Retours`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Feedback" |
|
||||
*
|
||||
* @param {Admin_Nav_FeedbackInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_FeedbackInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_feedback(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_feedback(inputs)
|
||||
if (locale === "id") return id_admin_nav_feedback(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_feedback(inputs)
|
||||
return fr_admin_nav_feedback(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_gitea.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_gitea.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_GiteaInputs */
|
||||
|
||||
const en_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gitea`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gitea`)
|
||||
};
|
||||
|
||||
const id_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gitea`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gitea`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gitea`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Gitea" |
|
||||
*
|
||||
* @param {Admin_Nav_GiteaInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_gitea = /** @type {((inputs?: Admin_Nav_GiteaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GiteaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_gitea(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_gitea(inputs)
|
||||
if (locale === "id") return id_admin_nav_gitea(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_gitea(inputs)
|
||||
return fr_admin_nav_gitea(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_image_gen.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_image_gen.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Image_GenInputs */
|
||||
|
||||
const en_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const id_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Image Gen`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Image Gen" |
|
||||
*
|
||||
* @param {Admin_Nav_Image_GenInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_image_gen = /** @type {((inputs?: Admin_Nav_Image_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Image_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_image_gen(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_image_gen(inputs)
|
||||
if (locale === "id") return id_admin_nav_image_gen(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_image_gen(inputs)
|
||||
return fr_admin_nav_image_gen(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_logs.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_logs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_LogsInputs */
|
||||
|
||||
const en_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Logs`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Логи`)
|
||||
};
|
||||
|
||||
const id_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Log`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Logs`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Journaux`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Logs" |
|
||||
*
|
||||
* @param {Admin_Nav_LogsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_LogsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_logs(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_logs(inputs)
|
||||
if (locale === "id") return id_admin_nav_logs(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_logs(inputs)
|
||||
return fr_admin_nav_logs(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_push.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_push.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_PushInputs */
|
||||
|
||||
const en_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Push`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Уведомления`)
|
||||
};
|
||||
|
||||
const id_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifikasi`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notificações`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Push" |
|
||||
*
|
||||
* @param {Admin_Nav_PushInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_PushInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_push(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_push(inputs)
|
||||
if (locale === "id") return id_admin_nav_push(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_push(inputs)
|
||||
return fr_admin_nav_push(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_scrape.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_scrape.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ScrapeInputs */
|
||||
|
||||
const en_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Scrape`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Скрейпинг`)
|
||||
};
|
||||
|
||||
const id_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Scrape`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Scrape`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Scrape`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Scrape" |
|
||||
*
|
||||
* @param {Admin_Nav_ScrapeInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ScrapeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_scrape(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_scrape(inputs)
|
||||
if (locale === "id") return id_admin_nav_scrape(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_scrape(inputs)
|
||||
return fr_admin_nav_scrape(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_text_gen.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Text_GenInputs */
|
||||
|
||||
const en_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const id_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Text Gen`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Text Gen" |
|
||||
*
|
||||
* @param {Admin_Nav_Text_GenInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_text_gen = /** @type {((inputs?: Admin_Nav_Text_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Text_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_text_gen(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_text_gen(inputs)
|
||||
if (locale === "id") return id_admin_nav_text_gen(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_text_gen(inputs)
|
||||
return fr_admin_nav_text_gen(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_translation.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_translation.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_TranslationInputs */
|
||||
|
||||
const en_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Translation`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Перевод`)
|
||||
};
|
||||
|
||||
const id_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Terjemahan`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tradução`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Traduction`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Translation" |
|
||||
*
|
||||
* @param {Admin_Nav_TranslationInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_translation(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_translation(inputs)
|
||||
if (locale === "id") return id_admin_nav_translation(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_translation(inputs)
|
||||
return fr_admin_nav_translation(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_uptime.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_uptime.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_UptimeInputs */
|
||||
|
||||
const en_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Uptime`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Мониторинг`)
|
||||
};
|
||||
|
||||
const id_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Uptime`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Uptime`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Disponibilité`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Uptime" |
|
||||
*
|
||||
* @param {Admin_Nav_UptimeInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_UptimeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_uptime(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_uptime(inputs)
|
||||
if (locale === "id") return id_admin_nav_uptime(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_uptime(inputs)
|
||||
return fr_admin_nav_uptime(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_applied.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_applied.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_AppliedInputs */
|
||||
|
||||
const en_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Applied`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применено`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Diterapkan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicado`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Appliqué`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Applied" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_AppliedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_applied = /** @type {((inputs?: Book_Detail_Admin_AppliedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_AppliedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_applied(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_applied(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_applied(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_applied(inputs)
|
||||
return fr_book_detail_admin_applied(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_apply.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_apply.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_ApplyInputs */
|
||||
|
||||
const en_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Apply`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применить`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Terapkan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Appliquer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Apply" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_ApplyInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_apply = /** @type {((inputs?: Book_Detail_Admin_ApplyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_apply(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_apply(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_apply(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_apply(inputs)
|
||||
return fr_book_detail_admin_apply(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_applying.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_applying.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_ApplyingInputs */
|
||||
|
||||
const en_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Applying…`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Применение…`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Menerapkan…`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Aplicando…`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Application…`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Applying…" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_ApplyingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_applying = /** @type {((inputs?: Book_Detail_Admin_ApplyingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_applying(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_applying(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_applying(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_applying(inputs)
|
||||
return fr_book_detail_admin_applying(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_audio_tts.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_audio_tts.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Audio_TtsInputs */
|
||||
|
||||
const en_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Аудио TTS`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Áudio TTS`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Audio TTS`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Audio TTS" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Audio_TtsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_audio_tts = /** @type {((inputs?: Book_Detail_Admin_Audio_TtsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Audio_TtsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_audio_tts(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_audio_tts(inputs)
|
||||
return fr_book_detail_admin_audio_tts(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Book_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Book Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Обложка книги`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Sampul Buku`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capa do Livro`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Couverture du livre`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Book Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Book_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_book_cover = /** @type {((inputs?: Book_Detail_Admin_Book_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Book_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_book_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_book_cover(inputs)
|
||||
return fr_book_detail_admin_book_cover(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Cancel_AudioInputs */
|
||||
|
||||
const en_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Cancel`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отмена`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Batal`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Cancelar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Annuler`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Cancel" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Cancel_AudioInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_cancel_audio = /** @type {((inputs?: Book_Detail_Admin_Cancel_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Cancel_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_cancel_audio(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_cancel_audio(inputs)
|
||||
return fr_book_detail_admin_cancel_audio(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Обложка главы`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Sampul Bab`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capa do Capítulo`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Couverture du chapitre`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_cover = /** @type {((inputs?: Book_Detail_Admin_Chapter_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_cover(inputs)
|
||||
return fr_book_detail_admin_chapter_cover(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_chapter_n.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_chapter_n.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_NInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter #`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Глава №`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Bab #`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Capítulo nº`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapitre n°`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter #" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_NInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_n = /** @type {((inputs?: Book_Detail_Admin_Chapter_NInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_n(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_n(inputs)
|
||||
return fr_book_detail_admin_chapter_n(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Chapter_NamesInputs */
|
||||
|
||||
const en_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Chapter Names`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Названия глав`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nama Bab`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nomes dos Capítulos`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Noms des chapitres`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Chapter Names" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Chapter_NamesInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_chapter_names = /** @type {((inputs?: Book_Detail_Admin_Chapter_NamesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NamesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_chapter_names(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_chapter_names(inputs)
|
||||
return fr_book_detail_admin_chapter_names(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_DescriptionInputs */
|
||||
|
||||
const en_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Description`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Описание`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Deskripsi`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Descrição`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Description`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Description" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_DescriptionInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_description = /** @type {((inputs?: Book_Detail_Admin_DescriptionInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DescriptionInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_description(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_description(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_description(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_description(inputs)
|
||||
return fr_book_detail_admin_description(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_discard.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_discard.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_DiscardInputs */
|
||||
|
||||
const en_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Discard`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Отменить`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Buang`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Descartar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Ignorer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Discard" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_DiscardInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_discard = /** @type {((inputs?: Book_Detail_Admin_DiscardInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DiscardInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_discard(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_discard(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_discard(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_discard(inputs)
|
||||
return fr_book_detail_admin_discard(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Enqueue_AudioInputs */
|
||||
|
||||
const en_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enqueue Audio`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Поставить в очередь`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Antre Audio`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enfileirar Áudio`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Mettre en file audio`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Enqueue Audio" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Enqueue_AudioInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_enqueue_audio = /** @type {((inputs?: Book_Detail_Admin_Enqueue_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Enqueue_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_enqueue_audio(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_enqueue_audio(inputs)
|
||||
return fr_book_detail_admin_enqueue_audio(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_enqueued.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_enqueued.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{ enqueued: NonNullable<unknown>, skipped: NonNullable<unknown> }} Book_Detail_Admin_EnqueuedInputs */
|
||||
|
||||
const en_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`Enqueued ${i?.enqueued}, skipped ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`В очереди ${i?.enqueued}, пропущено ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`Diantre ${i?.enqueued}, dilewati ${i?.skipped}`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.enqueued} enfileirados, ${i?.skipped} ignorados`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.enqueued} en file, ${i?.skipped} ignorés`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Enqueued {enqueued}, skipped {skipped}" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_EnqueuedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_enqueued = /** @type {((inputs: Book_Detail_Admin_EnqueuedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_EnqueuedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_enqueued(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_enqueued(inputs)
|
||||
return fr_book_detail_admin_enqueued(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_generate.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_generate.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_GenerateInputs */
|
||||
|
||||
const en_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Generate`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сгенерировать`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Buat`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Gerar`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Générer`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Generate" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_GenerateInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_generate = /** @type {((inputs?: Book_Detail_Admin_GenerateInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_GenerateInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_generate(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_generate(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_generate(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_generate(inputs)
|
||||
return fr_book_detail_admin_generate(inputs)
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_Save_CoverInputs */
|
||||
|
||||
const en_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Save Cover`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранить обложку`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Simpan Sampul`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvar Capa`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistrer la couverture`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Save Cover" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_Save_CoverInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_save_cover = /** @type {((inputs?: Book_Detail_Admin_Save_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Save_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_save_cover(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_save_cover(inputs)
|
||||
return fr_book_detail_admin_save_cover(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_saved.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_saved.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_SavedInputs */
|
||||
|
||||
const en_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Saved`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранено`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tersimpan`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvo`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistré`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Saved" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_SavedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_saved = /** @type {((inputs?: Book_Detail_Admin_SavedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_saved(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_saved(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_saved(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_saved(inputs)
|
||||
return fr_book_detail_admin_saved(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_saving.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_saving.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_SavingInputs */
|
||||
|
||||
const en_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Saving…`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Сохранение…`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Menyimpan…`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Salvando…`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Enregistrement…`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Saving…" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_SavingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_saving = /** @type {((inputs?: Book_Detail_Admin_SavingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_saving(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_saving(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_saving(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_saving(inputs)
|
||||
return fr_book_detail_admin_saving(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/book_detail_admin_voice.js
Normal file
44
ui/src/lib/paraglide/messages/book_detail_admin_voice.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Book_Detail_Admin_VoiceInputs */
|
||||
|
||||
const en_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voice`)
|
||||
};
|
||||
|
||||
const ru_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Голос`)
|
||||
};
|
||||
|
||||
const id_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Suara`)
|
||||
};
|
||||
|
||||
const pt_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voz`)
|
||||
};
|
||||
|
||||
const fr_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Voix`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Voice" |
|
||||
*
|
||||
* @param {Book_Detail_Admin_VoiceInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const book_detail_admin_voice = /** @type {((inputs?: Book_Detail_Admin_VoiceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_VoiceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_book_detail_admin_voice(inputs)
|
||||
if (locale === "ru") return ru_book_detail_admin_voice(inputs)
|
||||
if (locale === "id") return id_book_detail_admin_voice(inputs)
|
||||
if (locale === "pt") return pt_book_detail_admin_voice(inputs)
|
||||
return fr_book_detail_admin_voice(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_browse_cta.js
Normal file
44
ui/src/lib/paraglide/messages/feed_browse_cta.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Browse_CtaInputs */
|
||||
|
||||
const en_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Browse catalogue`)
|
||||
};
|
||||
|
||||
const ru_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Каталог`)
|
||||
};
|
||||
|
||||
const id_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Jelajahi katalog`)
|
||||
};
|
||||
|
||||
const pt_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Ver catálogo`)
|
||||
};
|
||||
|
||||
const fr_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Parcourir le catalogue`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Browse catalogue" |
|
||||
*
|
||||
* @param {Feed_Browse_CtaInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_browse_cta = /** @type {((inputs?: Feed_Browse_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Browse_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_browse_cta(inputs)
|
||||
if (locale === "ru") return ru_feed_browse_cta(inputs)
|
||||
if (locale === "id") return id_feed_browse_cta(inputs)
|
||||
if (locale === "pt") return pt_feed_browse_cta(inputs)
|
||||
return fr_feed_browse_cta(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_chapters_label.js
Normal file
44
ui/src/lib/paraglide/messages/feed_chapters_label.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{ n: NonNullable<unknown> }} Feed_Chapters_LabelInputs */
|
||||
|
||||
const en_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.n} chapters`)
|
||||
};
|
||||
|
||||
const ru_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.n} глав`)
|
||||
};
|
||||
|
||||
const id_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.n} bab`)
|
||||
};
|
||||
|
||||
const pt_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.n} capítulos`)
|
||||
};
|
||||
|
||||
const fr_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
|
||||
return /** @type {LocalizedString} */ (`${i?.n} chapitres`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "{n} chapters" |
|
||||
*
|
||||
* @param {Feed_Chapters_LabelInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_chapters_label = /** @type {((inputs: Feed_Chapters_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Chapters_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_chapters_label(inputs)
|
||||
if (locale === "ru") return ru_feed_chapters_label(inputs)
|
||||
if (locale === "id") return id_feed_chapters_label(inputs)
|
||||
if (locale === "pt") return pt_feed_chapters_label(inputs)
|
||||
return fr_feed_chapters_label(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_empty_body.js
Normal file
44
ui/src/lib/paraglide/messages/feed_empty_body.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Empty_BodyInputs */
|
||||
|
||||
const en_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Follow other readers to see what they're reading.`)
|
||||
};
|
||||
|
||||
const ru_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Подпишитесь на других читателей, чтобы видеть, что они читают.`)
|
||||
};
|
||||
|
||||
const id_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Ikuti pembaca lain untuk melihat apa yang mereka baca.`)
|
||||
};
|
||||
|
||||
const pt_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Siga outros leitores para ver o que estão lendo.`)
|
||||
};
|
||||
|
||||
const fr_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Suivez d'autres lecteurs pour voir ce qu'ils lisent.`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Follow other readers to see what they're reading." |
|
||||
*
|
||||
* @param {Feed_Empty_BodyInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_empty_body = /** @type {((inputs?: Feed_Empty_BodyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_BodyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_empty_body(inputs)
|
||||
if (locale === "ru") return ru_feed_empty_body(inputs)
|
||||
if (locale === "id") return id_feed_empty_body(inputs)
|
||||
if (locale === "pt") return pt_feed_empty_body(inputs)
|
||||
return fr_feed_empty_body(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_empty_heading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_empty_heading.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Empty_HeadingInputs */
|
||||
|
||||
const en_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nothing here yet`)
|
||||
};
|
||||
|
||||
const ru_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Пока ничего нет`)
|
||||
};
|
||||
|
||||
const id_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Belum ada apa-apa`)
|
||||
};
|
||||
|
||||
const pt_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Nada aqui ainda`)
|
||||
};
|
||||
|
||||
const fr_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Rien encore`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Nothing here yet" |
|
||||
*
|
||||
* @param {Feed_Empty_HeadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_empty_heading = /** @type {((inputs?: Feed_Empty_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_empty_heading(inputs)
|
||||
if (locale === "ru") return ru_feed_empty_heading(inputs)
|
||||
if (locale === "id") return id_feed_empty_heading(inputs)
|
||||
if (locale === "pt") return pt_feed_empty_heading(inputs)
|
||||
return fr_feed_empty_heading(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_find_users_cta.js
Normal file
44
ui/src/lib/paraglide/messages/feed_find_users_cta.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Find_Users_CtaInputs */
|
||||
|
||||
const en_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Discover readers`)
|
||||
};
|
||||
|
||||
const ru_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Найти читателей`)
|
||||
};
|
||||
|
||||
const id_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Temukan pembaca`)
|
||||
};
|
||||
|
||||
const pt_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Encontrar leitores`)
|
||||
};
|
||||
|
||||
const fr_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Trouver des lecteurs`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Discover readers" |
|
||||
*
|
||||
* @param {Feed_Find_Users_CtaInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_find_users_cta = /** @type {((inputs?: Feed_Find_Users_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Find_Users_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_find_users_cta(inputs)
|
||||
if (locale === "ru") return ru_feed_find_users_cta(inputs)
|
||||
if (locale === "id") return id_feed_find_users_cta(inputs)
|
||||
if (locale === "pt") return pt_feed_find_users_cta(inputs)
|
||||
return fr_feed_find_users_cta(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_heading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_heading.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_HeadingInputs */
|
||||
|
||||
const en_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Following Feed`)
|
||||
};
|
||||
|
||||
const ru_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Лента подписок`)
|
||||
};
|
||||
|
||||
const id_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Umpan Ikutan`)
|
||||
};
|
||||
|
||||
const pt_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feed de seguidos`)
|
||||
};
|
||||
|
||||
const fr_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Fil d'abonnements`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Following Feed" |
|
||||
*
|
||||
* @param {Feed_HeadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_heading = /** @type {((inputs?: Feed_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_heading(inputs)
|
||||
if (locale === "ru") return ru_feed_heading(inputs)
|
||||
if (locale === "id") return id_feed_heading(inputs)
|
||||
if (locale === "pt") return pt_feed_heading(inputs)
|
||||
return fr_feed_heading(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_not_logged_in.js
Normal file
44
ui/src/lib/paraglide/messages/feed_not_logged_in.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Not_Logged_InInputs */
|
||||
|
||||
const en_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Sign in to see your feed.`)
|
||||
};
|
||||
|
||||
const ru_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Войдите, чтобы видеть свою ленту.`)
|
||||
};
|
||||
|
||||
const id_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Masuk untuk melihat umpan Anda.`)
|
||||
};
|
||||
|
||||
const pt_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Faça login para ver seu feed.`)
|
||||
};
|
||||
|
||||
const fr_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Connectez-vous pour voir votre fil.`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Sign in to see your feed." |
|
||||
*
|
||||
* @param {Feed_Not_Logged_InInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_not_logged_in = /** @type {((inputs?: Feed_Not_Logged_InInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Not_Logged_InInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_not_logged_in(inputs)
|
||||
if (locale === "ru") return ru_feed_not_logged_in(inputs)
|
||||
if (locale === "id") return id_feed_not_logged_in(inputs)
|
||||
if (locale === "pt") return pt_feed_not_logged_in(inputs)
|
||||
return fr_feed_not_logged_in(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_page_title.js
Normal file
44
ui/src/lib/paraglide/messages/feed_page_title.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Page_TitleInputs */
|
||||
|
||||
const en_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
|
||||
};
|
||||
|
||||
const ru_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Лента — LibNovel`)
|
||||
};
|
||||
|
||||
const id_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Umpan — LibNovel`)
|
||||
};
|
||||
|
||||
const pt_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
|
||||
};
|
||||
|
||||
const fr_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Fil — LibNovel`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Feed — LibNovel" |
|
||||
*
|
||||
* @param {Feed_Page_TitleInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_page_title = /** @type {((inputs?: Feed_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_page_title(inputs)
|
||||
if (locale === "ru") return ru_feed_page_title(inputs)
|
||||
if (locale === "id") return id_feed_page_title(inputs)
|
||||
if (locale === "pt") return pt_feed_page_title(inputs)
|
||||
return fr_feed_page_title(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_reader_label.js
Normal file
44
ui/src/lib/paraglide/messages/feed_reader_label.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_Reader_LabelInputs */
|
||||
|
||||
const en_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`reading`)
|
||||
};
|
||||
|
||||
const ru_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`читает`)
|
||||
};
|
||||
|
||||
const id_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`membaca`)
|
||||
};
|
||||
|
||||
const pt_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`lendo`)
|
||||
};
|
||||
|
||||
const fr_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`lit`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "reading" |
|
||||
*
|
||||
* @param {Feed_Reader_LabelInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_reader_label = /** @type {((inputs?: Feed_Reader_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Reader_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_reader_label(inputs)
|
||||
if (locale === "ru") return ru_feed_reader_label(inputs)
|
||||
if (locale === "id") return id_feed_reader_label(inputs)
|
||||
if (locale === "pt") return pt_feed_reader_label(inputs)
|
||||
return fr_feed_reader_label(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/feed_subheading.js
Normal file
44
ui/src/lib/paraglide/messages/feed_subheading.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Feed_SubheadingInputs */
|
||||
|
||||
const en_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Books your followed users are reading`)
|
||||
};
|
||||
|
||||
const ru_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Книги, которые читают ваши подписки`)
|
||||
};
|
||||
|
||||
const id_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Buku yang sedang dibaca oleh pengguna yang Anda ikuti`)
|
||||
};
|
||||
|
||||
const pt_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Livros que seus seguidos estão lendo`)
|
||||
};
|
||||
|
||||
const fr_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Livres lus par vos abonnements`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Books your followed users are reading" |
|
||||
*
|
||||
* @param {Feed_SubheadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const feed_subheading = /** @type {((inputs?: Feed_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_feed_subheading(inputs)
|
||||
if (locale === "ru") return ru_feed_subheading(inputs)
|
||||
if (locale === "id") return id_feed_subheading(inputs)
|
||||
if (locale === "pt") return pt_feed_subheading(inputs)
|
||||
return fr_feed_subheading(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/nav_feed.js
Normal file
44
ui/src/lib/paraglide/messages/nav_feed.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Nav_FeedInputs */
|
||||
|
||||
const en_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feed`)
|
||||
};
|
||||
|
||||
const ru_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Лента`)
|
||||
};
|
||||
|
||||
const id_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Umpan`)
|
||||
};
|
||||
|
||||
const pt_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Feed`)
|
||||
};
|
||||
|
||||
const fr_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Fil`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Feed" |
|
||||
*
|
||||
* @param {Nav_FeedInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const nav_feed = /** @type {((inputs?: Nav_FeedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Nav_FeedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_nav_feed(inputs)
|
||||
if (locale === "ru") return ru_nav_feed(inputs)
|
||||
if (locale === "id") return id_nav_feed(inputs)
|
||||
if (locale === "pt") return pt_nav_feed(inputs)
|
||||
return fr_nav_feed(inputs)
|
||||
});
|
||||
@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
|
||||
const BOOKS_CACHE_KEY = 'books:all';
|
||||
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
const RATINGS_CACHE_KEY = 'book_ratings:all';
|
||||
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
const HOME_STATS_CACHE_KEY = 'home:stats';
|
||||
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
|
||||
|
||||
async function getAllRatings(): Promise<BookRating[]> {
|
||||
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
|
||||
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
|
||||
return ratings;
|
||||
}
|
||||
|
||||
export async function listBooks(): Promise<Book[]> {
|
||||
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
|
||||
if (cached) {
|
||||
@@ -258,13 +272,36 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
|
||||
// Build filter: slug='a' || slug='b' || ...
|
||||
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
|
||||
const books = await listAll<Book>('books', filter, '+title');
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
return books;
|
||||
|
||||
// Deduplicate by slug — PocketBase may have multiple records for the same
|
||||
// slug if the scraper ran concurrently or the upsert raced. First record wins.
|
||||
const seen = new Set<string>();
|
||||
const deduped = books.filter((b) => {
|
||||
if (seen.has(b.slug)) return false;
|
||||
seen.add(b.slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (deduped.length !== books.length) {
|
||||
log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
|
||||
requested: slugArr.length,
|
||||
raw: books.length,
|
||||
deduped: deduped.length
|
||||
});
|
||||
} else {
|
||||
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/** Invalidate the books cache (call after a book is created/updated/deleted). */
|
||||
export async function invalidateBooksCache(): Promise<void> {
|
||||
await cache.invalidate(BOOKS_CACHE_KEY);
|
||||
await Promise.all([
|
||||
cache.invalidate(BOOKS_CACHE_KEY),
|
||||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||
cache.invalidatePattern('books:recent:*'),
|
||||
cache.invalidatePattern('books:recently-updated:*')
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
@@ -272,7 +309,53 @@ export async function getBook(slug: string): Promise<Book | null> {
|
||||
}
|
||||
|
||||
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
return listN<Book>('books', limit, '', '-meta_updated');
|
||||
const key = `books:recent:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
||||
await cache.set(key, books, 5 * 60);
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Books with the most recently added chapters, ordered by chapter insertion time.
|
||||
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
|
||||
* This correctly reflects actual chapter activity, unlike meta_updated on books.
|
||||
*/
|
||||
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
|
||||
const key = `books:recently-updated:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
// Fetch enough recent chapter rows to find `limit` distinct books
|
||||
const rows = await listN<{ slug: string; created: string }>(
|
||||
'chapters_idx', limit * 25, '', '-created'
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const slugs: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!seen.has(row.slug)) {
|
||||
seen.add(row.slug);
|
||||
slugs.push(row.slug);
|
||||
if (slugs.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!slugs.length) return recentlyAddedBooks(limit);
|
||||
|
||||
const books = await getBooksBySlugs(new Set(slugs));
|
||||
// Restore recency order (getBooksBySlugs returns in title sort order)
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
|
||||
|
||||
await cache.set(key, ordered, 5 * 60);
|
||||
return ordered;
|
||||
} catch {
|
||||
// Fall back to meta_updated sort if chapters_idx query fails
|
||||
return recentlyAddedBooks(limit);
|
||||
}
|
||||
}
|
||||
|
||||
export interface HomeStats {
|
||||
@@ -281,11 +364,19 @@ export interface HomeStats {
|
||||
}
|
||||
|
||||
export async function getHomeStats(): Promise<HomeStats> {
|
||||
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const [totalBooks, totalChapters] = await Promise.all([
|
||||
countCollection('books'),
|
||||
countCollection('chapters_idx')
|
||||
]);
|
||||
return { totalBooks, totalChapters };
|
||||
const stats = { totalBooks, totalChapters };
|
||||
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function invalidateHomeStatsCache(): Promise<void> {
|
||||
await cache.invalidate(HOME_STATS_CACHE_KEY);
|
||||
}
|
||||
|
||||
// ─── Chapter index ────────────────────────────────────────────────────────────
|
||||
@@ -524,7 +615,7 @@ export async function unsaveBook(
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
|
||||
|
||||
function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
@@ -948,6 +1039,15 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
|
||||
return listAll<AudioJob>('audio_jobs', '', '-started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of book slugs that have at least one completed audio job.
|
||||
* Used by the catalogue page to show audio-available badges.
|
||||
*/
|
||||
export async function getSlugsWithAudio(): Promise<Set<string>> {
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||||
return new Set(jobs.map((j) => j.slug));
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
@@ -985,12 +1085,79 @@ export interface UserSession {
|
||||
session_id: string; // the auth session ID embedded in the token
|
||||
user_agent: string;
|
||||
ip: string;
|
||||
device_fingerprint: string;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session record on login. Returns the record ID.
|
||||
* Generate a short device fingerprint from user-agent + IP.
|
||||
* SHA-256 of the concatenation, first 16 hex chars.
|
||||
*/
|
||||
function deviceFingerprint(userAgent: string, ip: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${userAgent}::${ip}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a session record on login.
|
||||
* - If a session already exists for this user + device fingerprint, touch it and
|
||||
* return the existing authSessionId (so the caller can reuse the same token).
|
||||
* - Otherwise create a new record.
|
||||
* Returns `{ authSessionId, recordId }`.
|
||||
*/
|
||||
export async function upsertUserSession(
|
||||
userId: string,
|
||||
authSessionId: string,
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<{ authSessionId: string; recordId: string }> {
|
||||
const fp = deviceFingerprint(userAgent, ip);
|
||||
|
||||
// Look for an existing session from the same device
|
||||
const existing = await listOne<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && device_fingerprint="${fp}"`
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Touch last_seen and return the existing authSessionId
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||
}).catch(() => {});
|
||||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||||
}
|
||||
|
||||
// Create a new session record
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
device_fingerprint: fp,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale/excess sessions in the background
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return { authSessionId, recordId: rec.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use upsertUserSession instead.
|
||||
* Kept temporarily so callers can be migrated incrementally.
|
||||
*/
|
||||
export async function createUserSession(
|
||||
userId: string,
|
||||
@@ -998,24 +1165,8 @@ export async function createUserSession(
|
||||
userAgent: string,
|
||||
ip: string
|
||||
): Promise<string> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await pbPost('/api/collections/user_sessions/records', {
|
||||
user_id: userId,
|
||||
session_id: authSessionId,
|
||||
user_agent: userAgent,
|
||||
ip,
|
||||
created_at: now,
|
||||
last_seen: now
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return rec.id;
|
||||
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
|
||||
return recordId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1052,20 +1203,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days,
|
||||
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
|
||||
* Called on login so the list self-cleans without a separate cron job.
|
||||
*/
|
||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const stale = await listAll<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
||||
);
|
||||
if (stale.length === 0) return;
|
||||
async function pruneStaleUserSessions(
|
||||
userId: string,
|
||||
days = 30,
|
||||
maxSessions = 10
|
||||
): Promise<void> {
|
||||
const token = await getToken();
|
||||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const toDelete = new Set<string>();
|
||||
|
||||
// Mark stale sessions
|
||||
for (const s of all) {
|
||||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||||
}
|
||||
|
||||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||||
const remaining = all.filter((s) => !toDelete.has(s.id));
|
||||
if (remaining.length > maxSessions) {
|
||||
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
|
||||
}
|
||||
|
||||
if (toDelete.size === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
[...toDelete].map((id) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
@@ -1644,3 +1812,353 @@ export async function getSubscriptionFeed(
|
||||
feed.sort((a, b) => b.updated.localeCompare(a.updated));
|
||||
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
|
||||
}
|
||||
|
||||
// ─── Discovery ────────────────────────────────────────────────────────────────
|
||||
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
|
||||
// - session_id (text, required)
|
||||
// - user_id (text, optional)
|
||||
// - slug (text, required)
|
||||
// - action (text, required) — one of: like | skip | nope | read_now
|
||||
|
||||
export interface DiscoveryVote {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
action: 'like' | 'skip' | 'nope' | 'read_now';
|
||||
}
|
||||
|
||||
export interface DiscoveryPrefs {
|
||||
genres: string[];
|
||||
status: 'either' | 'ongoing' | 'completed';
|
||||
}
|
||||
|
||||
function parseGenresLocal(genres: string[] | string): string[] {
|
||||
if (Array.isArray(genres)) return genres;
|
||||
if (!genres) return [];
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
if (userId) return `user_id="${userId}"`;
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
action: DiscoveryVote['action'],
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
|
||||
const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
|
||||
} else {
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
const filter = discoveryFilter(sessionId, userId);
|
||||
const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookRating {
|
||||
session_id: string;
|
||||
user_id?: string;
|
||||
slug: string;
|
||||
rating: number; // 1–5
|
||||
}
|
||||
|
||||
export async function getBookRating(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<number> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
|
||||
return row?.rating ?? 0;
|
||||
}
|
||||
|
||||
export async function getBookAvgRating(
|
||||
slug: string
|
||||
): Promise<{ avg: number; count: number }> {
|
||||
const rows = await listAll<BookRating>('book_ratings', `slug="${slug}"`).catch(() => []);
|
||||
if (!rows.length) return { avg: 0, count: 0 };
|
||||
const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length;
|
||||
return { avg: Math.round(avg * 10) / 10, count: rows.length };
|
||||
}
|
||||
|
||||
export async function setBookRating(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
rating: number,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
|
||||
const payload: Partial<BookRating> = { session_id: sessionId, slug, rating };
|
||||
if (userId) payload.user_id = userId;
|
||||
if (existing) {
|
||||
await pbPatch(`/api/collections/book_ratings/records/${existing.id}`, payload);
|
||||
} else {
|
||||
await pbPost('/api/collections/book_ratings/records', payload);
|
||||
}
|
||||
await cache.invalidate(RATINGS_CACHE_KEY);
|
||||
}
|
||||
|
||||
// ─── Shelves ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';
|
||||
|
||||
export async function updateBookShelf(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
shelf: ShelfName,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = userId
|
||||
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
|
||||
: `session_id="${sessionId}" && slug="${slug}"`;
|
||||
const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
|
||||
if (!existing) {
|
||||
// Save + set shelf in one shot
|
||||
const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
|
||||
if (userId) payload.user_id = userId;
|
||||
await pbPost('/api/collections/user_library/records', payload);
|
||||
} else {
|
||||
await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShelfMap(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<Record<string, ShelfName>> {
|
||||
const filter = userId
|
||||
? `session_id="${sessionId}" || user_id="${userId}"`
|
||||
: `session_id="${sessionId}"`;
|
||||
const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
|
||||
const map: Record<string, ShelfName> = {};
|
||||
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getBooksForDiscovery(
|
||||
sessionId: string,
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
|
||||
if (prefs?.genres?.length) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
const genreFiltered = candidates.filter((b) => {
|
||||
const genres = parseGenresLocal(b.genres);
|
||||
return genres.some((g) => preferred.has(g.toLowerCase()));
|
||||
});
|
||||
if (genreFiltered.length >= 5) candidates = genreFiltered;
|
||||
}
|
||||
|
||||
if (prefs?.status && prefs.status !== 'either') {
|
||||
const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
cur.sum += r.rating;
|
||||
cur.count += 1;
|
||||
ratingMap.set(r.slug, cur);
|
||||
}
|
||||
const avgRating = (slug: string) => {
|
||||
const e = ratingMap.get(slug);
|
||||
return e && e.count > 0 ? e.sum / e.count : 0;
|
||||
};
|
||||
|
||||
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
|
||||
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
|
||||
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
|
||||
|
||||
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
|
||||
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 0–10
|
||||
let start = 0;
|
||||
while (start < candidates.length) {
|
||||
let end = start + 1;
|
||||
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
|
||||
for (let i = end - 1; i > start; i--) {
|
||||
const j = start + Math.floor(Math.random() * (i - start + 1));
|
||||
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
|
||||
return candidates.slice(0, 50);
|
||||
}
|
||||
|
||||
// ─── Discovery history ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface VotedBook {
|
||||
slug: string;
|
||||
action: DiscoveryVote['action'];
|
||||
votedAt: string;
|
||||
book?: Book;
|
||||
}
|
||||
|
||||
export async function getVotedBooks(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<VotedBook[]> {
|
||||
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId),
|
||||
'-created'
|
||||
).catch(() => []);
|
||||
|
||||
if (!votes.length) return [];
|
||||
|
||||
const slugs = [...new Set(votes.map((v) => v.slug))];
|
||||
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
|
||||
return votes.map((v) => ({
|
||||
slug: v.slug,
|
||||
action: v.action,
|
||||
votedAt: v.created,
|
||||
book: bookMap.get(v.slug)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function undoDiscoveryVote(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
|
||||
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserStats {
|
||||
totalChaptersRead: number;
|
||||
booksReading: number;
|
||||
booksCompleted: number;
|
||||
booksPlanToRead: number;
|
||||
booksDropped: number;
|
||||
topGenres: string[]; // top 3 by frequency
|
||||
avgRatingGiven: number; // 0 if no ratings
|
||||
streak: number; // consecutive days with progress
|
||||
}
|
||||
|
||||
export async function getUserStats(
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<UserStats> {
|
||||
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||
|
||||
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
|
||||
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
|
||||
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
|
||||
listAll<BookRating>('book_ratings', filter).catch(() => []),
|
||||
listBooks().catch(() => [] as Book[])
|
||||
]);
|
||||
|
||||
// shelf counts
|
||||
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
|
||||
for (const r of libraryRows) {
|
||||
const s = r.shelf || 'reading';
|
||||
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
|
||||
}
|
||||
|
||||
// top genres from books in progress/library
|
||||
const libSlugs = new Set(libraryRows.map((r) => r.slug));
|
||||
const progSlugs = new Set(progressRows.map((r) => r.slug));
|
||||
const allSlugs = new Set([...libSlugs, ...progSlugs]);
|
||||
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
|
||||
const genreFreq = new Map<string, number>();
|
||||
for (const slug of allSlugs) {
|
||||
const book = bookMap.get(slug);
|
||||
if (!book) continue;
|
||||
for (const g of parseGenresLocal(book.genres)) {
|
||||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const topGenres = [...genreFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
|
||||
// avg rating given
|
||||
const avgRatingGiven =
|
||||
ratingRows.length > 0
|
||||
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
|
||||
: 0;
|
||||
|
||||
// reading streak: count consecutive calendar days (UTC) with a progress update
|
||||
const days = new Set(
|
||||
progressRows
|
||||
.filter((r) => r.updated)
|
||||
.map((r) => r.updated.slice(0, 10))
|
||||
);
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||||
else if (i > 0) break; // gap — stop
|
||||
}
|
||||
|
||||
return {
|
||||
totalChaptersRead: progressRows.length,
|
||||
booksReading: shelfCounts.reading,
|
||||
booksCompleted: shelfCounts.completed,
|
||||
booksPlanToRead: shelfCounts.plan_to_read,
|
||||
booksDropped: shelfCounts.dropped,
|
||||
topGenres,
|
||||
avgRatingGiven,
|
||||
streak
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
* Product IDs (Polar dashboard):
|
||||
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
|
||||
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
|
||||
*
|
||||
* Webhook event data shapes (Polar v1 API):
|
||||
* subscription.* → data.customer_id, data.product_id, data.status, data.customer.email
|
||||
* order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason
|
||||
*/
|
||||
|
||||
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';
|
||||
import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
|
||||
export const POLAR_PRO_PRODUCT_IDS = new Set([
|
||||
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
|
||||
@@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo
|
||||
|
||||
// ─── Subscription event handler ───────────────────────────────────────────────
|
||||
|
||||
interface PolarCustomer {
|
||||
email?: string;
|
||||
external_id?: string; // our app_users.id if set on the customer
|
||||
}
|
||||
|
||||
interface PolarSubscription {
|
||||
id: string;
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | ...
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
customer_email?: string;
|
||||
user_id?: string; // Polar user id (not our user id)
|
||||
customer?: PolarCustomer; // nested object — email lives here
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app_user for a Polar customer.
|
||||
* Priority: polar_customer_id → email → customer.external_id (our user ID)
|
||||
*/
|
||||
async function resolveUser(customer_id: string, customer?: PolarCustomer) {
|
||||
const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase');
|
||||
|
||||
// 1. By stored polar_customer_id (fastest on repeat events)
|
||||
const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (byCustomerId) return byCustomerId;
|
||||
|
||||
// 2. By email (most common first-time path)
|
||||
const email = customer?.email;
|
||||
if (email) {
|
||||
const byEmail = await getUserByEmail(email).catch(() => null);
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
// 3. By external_id = our user ID (if set via Polar API on customer creation)
|
||||
const externalId = customer?.external_id;
|
||||
if (externalId) {
|
||||
const byId = await getUserById(externalId).catch(() => null);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Polar subscription event.
|
||||
* Finds the matching app_user by email and updates role + polar fields.
|
||||
* Finds the matching app_user 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;
|
||||
const { id: subId, status, product_id, customer_id, customer } = subscription;
|
||||
|
||||
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
|
||||
log.info('polar', 'subscription event', {
|
||||
eventType, subId, status, product_id,
|
||||
customer_email: 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);
|
||||
}
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
|
||||
if (!user) {
|
||||
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
|
||||
log.warn('polar', 'no app_user found for polar customer', {
|
||||
customer_email: customer?.email,
|
||||
customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,5 +135,60 @@ export async function handleSubscriptionEvent(
|
||||
polar_subscription_id: isActive ? subId : ''
|
||||
});
|
||||
|
||||
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
|
||||
log.info('polar', 'user role updated', {
|
||||
userId: user.id, username: user.username, newRole, status
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Order event handler ──────────────────────────────────────────────────────
|
||||
|
||||
interface PolarOrder {
|
||||
id: string;
|
||||
status: string;
|
||||
billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update"
|
||||
product_id: string | null;
|
||||
customer_id: string;
|
||||
subscription_id: string | null;
|
||||
customer?: PolarCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order.created — used for initial subscription purchases.
|
||||
* We only act on subscription_create billing_reason to avoid double-processing
|
||||
* (subscription.active will also fire, but this ensures we catch edge cases).
|
||||
*/
|
||||
export async function handleOrderCreated(order: PolarOrder): Promise<void> {
|
||||
const { id: orderId, billing_reason, product_id, customer_id, customer } = order;
|
||||
|
||||
log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email });
|
||||
|
||||
// Only handle new subscription purchases here; renewals are handled by subscription.updated
|
||||
if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') {
|
||||
log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) {
|
||||
log.debug('polar', 'order.created — product not a pro product', { product_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
if (!user) {
|
||||
log.warn('polar', 'order.created — no app_user found', {
|
||||
customer_email: customer?.email, customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upgrade if not already pro/admin — subscription.active will do a full sync too
|
||||
if (user.role !== 'pro' && user.role !== 'admin') {
|
||||
await patchUser(user.id, {
|
||||
role: 'pro',
|
||||
polar_customer_id: customer_id
|
||||
});
|
||||
log.info('polar', 'order.created — user upgraded to pro', {
|
||||
userId: user.id, username: user.username
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user