Compare commits

...

70 Commits

Author SHA1 Message Date
Admin
ad2d1a2603 feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
2026-04-05 00:32:18 +05:00
Admin
b0d8c02787 fix: add created field to chapters_idx to fix recentlyUpdatedBooks 400
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.

- Add created date field to chapters_idx schema in pb-init-v3.sh
  (also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
  chapters are immediately sortable; existing records retain empty created
  and will sort to the back (acceptable — only affects home page recency)
2026-04-04 23:47:23 +05:00
Admin
5b4c1db931 fix: add watchtower label to runner service so auto-updates work
Without com.centurylinklabs.watchtower.enable=true the homelab watchtower
(running with --label-enable) silently skipped the runner container,
leaving it stuck on v2.5.60 while fixes accumulated on newer tags.
2026-04-04 23:39:19 +05:00
Admin
0c54c59586 fix: guard against font_size=0 collapsing chapter text
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m1s
Release / Gitea Release (push) Successful in 41s
- Replace ?? with || when reading font_size so 0 falls back to 1.0
  (affects GET /api/settings, layout.server.ts, +layout.svelte)
- Remove the explicit 'body.fontSize !== 0' exception in PUT /api/settings
  validation so 0 is now correctly rejected as an invalid font size
- Add add_index helper + idx_chapters_idx_slug_number declaration to
  scripts/pb-init-v3.sh (idempotent UNIQUE INDEX on chapters_idx)
2026-04-04 23:26:54 +05:00
Admin
0e5eb84097 feat: add SvelteKit proxy route for admin dedup-chapters endpoint
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 6m12s
Release / Docker / runner (push) Successful in 3m8s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 47s
2026-04-04 22:34:26 +05:00
Admin
6ef82a1d12 fix: add DeduplicateChapters stub to test mocks to satisfy BookWriter interface
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
2026-04-04 21:17:55 +05:00
Admin
7a418ee62b fix: await marked() to prevent Promise being passed as chapter HTML
Some checks failed
Release / Test backend (push) Failing after 15s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / ui (push) Successful in 2m41s
Release / Gitea Release (push) Has been skipped
marked() returns string | Promise<string>; the previous cast 'as string'
silently passed a Promise object, which Svelte rendered as nothing.
Free users saw blank content even though SSR HTML was correct.
2026-04-04 21:15:06 +05:00
Admin
d4f35a4899 fix: prevent duplicate chapters_idx records + add dedup endpoint
Some checks failed
Release / Test backend (push) Failing after 18s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata)
  so concurrent goroutines don't double-POST the same chapter number
- Add DeduplicateChapters to BookWriter interface and Store implementation;
  keeps the latest record per (slug, number) and deletes extras
- Wire POST /api/admin/dedup-chapters/{slug} handler in server.go
2026-04-04 21:00:10 +05:00
Admin
6559a8c015 fix: split long text into chunks before sending to Cloudflare AI TTS
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.

GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
2026-04-04 20:45:22 +05:00
Admin
05bfd110b8 refactor: replace floating settings panel with bottom sheet + Reading/Listening tabs
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
The old vertical dropdown was too tall on mobile — 14 stacked groups
required heavy scrolling and had no visual hierarchy.

New design:
- Full-width bottom sheet slides from screen edge (natural mobile gesture)
- Drag handle + dimmed backdrop for clarity
- Two tabs split the settings: Reading (typography + layout) and Listening
  (player style, speed, auto-next, sleep timer)
- Each row uses label-on-left + pill-group-on-right layout — saves one line
  per setting and makes the list scannable at a glance
- Settings are grouped into titled cards (Typography, Layout, Player)
  with dividers between rows instead of floating individual blocks
- Gear button moved to bottom-[4.5rem] to clear the mini-player bar
2026-04-04 20:35:15 +05:00
Admin
bfd0ad8fb7 fix: chapter content vanishes — replace stale untrack snapshots with $derived
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / ui (push) Successful in 4m58s
Release / Gitea Release (push) Has been cancelled
html and fetchingContent were captured with untrack() at mount time.
When SvelteKit re-ran the page load (triggered by the layout's settings PUT),
data.html updated but html stayed stale. The {#key} block in the layout then
destroyed and recreated the component, and on remount data.html was momentarily
empty so html became '' and the live-scrape fallback ran unnecessarily.

Fix:
- html is now $derived(scrapedHtml || data.html || '') — always tracks load
- scrapedHtml is a separate $state only set by the live-scrape fallback
- fetchingContent starts false; the fallback sets it true only when actually fetching
- translationStatus/translatingLang: dropped untrack() so they also react to re-runs
- Removed unused untrack import
2026-04-04 20:26:43 +05:00
Admin
4b7fcf432b fix: pass POLAR_API_TOKEN and POLAR_WEBHOOK_SECRET to ui container
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 43s
Both vars were present in Doppler but never injected into the ui service
environment, causing all checkout requests to fail with 500 and webhooks
to be silently rejected.
2026-04-04 20:18:19 +05:00
Admin
c4a0256f6e feat: /subscribe pricing page + Pro nav link + fix checkout token scope
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 41s
- Add /subscribe route with hero, benefits list, and pricing cards
  (annual featured with Save 33% badge, monthly secondary)
- Add 'Pro' link in nav for non-Pro users
- Add 'See plans' link in profile subscription section
- i18n keys across en/fr/id/pt/ru for all subscribe strings

Note: checkout still requires POLAR_API_TOKEN with checkouts:write scope.
Regenerate the token at polar.sh to fix the 502 error on subscribe buttons.
2026-04-04 20:12:24 +05:00
Admin
18f490f790 feat: admin controls on book detail page (cover/desc/chapter-names/audio TTS)
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m22s
Release / Docker / runner (push) Successful in 2m51s
Release / Docker / ui (push) Successful in 3m17s
Release / Gitea Release (push) Successful in 1m12s
Add 5 admin sections to /books/[slug] for admins:
- Book cover generation (CF AI image-gen with preview + save)
- Chapter cover generation (chapter number input + preview)
- Description regeneration (preview + apply/discard)
- Chapter names generation (preview table + apply/discard)
- Audio TTS bulk enqueue (voice selector, chapter range, cancel)

Also adds /api/admin/audio/bulk and /api/admin/audio/cancel-bulk proxy routes,
and all i18n keys across en/fr/id/pt/ru.
2026-04-04 19:57:16 +05:00
Admin
6456e8cf5d perf: skip ffmpeg transcode for PocketTTS streaming — use WAV directly
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / ui (push) Successful in 2m19s
Release / Gitea Release (push) Successful in 36s
PocketTTS emits 16-bit PCM WAV (16 kHz mono). WAV is natively supported
on all browsers including iOS/macOS Safari, so the ffmpeg MP3 transcode
is unnecessary for the streaming path.

Using format=wav for PocketTTS voices eliminates the ffmpeg subprocess
startup delay (~200–400 ms) and a pipeline stage, giving lower latency
to first audio frame. Kokoro and CF AI continue using MP3 (they output
MP3 natively or via the OpenAI-compatible endpoint).

The runner (MinIO storage) is unaffected — it still stores MP3 for
space efficiency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:55:05 +05:00
Admin
25150c2284 feat: TTS streaming — Kokoro/PocketTTS audio starts immediately
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 4m38s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m13s
Release / Gitea Release (push) Successful in 57s
Previously all voices waited for full TTS generation before first byte
reached the browser (POST → poll → presign flow). For long chapters this
meant 2–5 minutes of silence.

New flow for Kokoro and PocketTTS:
- tryPresign fast path unchanged (audio in MinIO → seekable presigned URL)
- On cache miss: set audioEl.src to /api/audio-stream/{slug}/{n} and mark
  status=ready immediately — audio starts playing within seconds
- Backend streams bytes to browser while concurrently uploading to MinIO;
  subsequent plays use the fast path

New SvelteKit route: GET /api/audio-stream/[slug]/[n]
- Proxies backend handleAudioStream (already implemented in Go)
- Same 3 chapters/day free paywall as POST route
- HEAD handler for paywall pre-check (no side effects, no counter increment)
  so AudioPlayer can surface upgrade CTA before pointing <audio> at URL

CF AI voices keep the old POST+poll flow (batch-only API, no real streaming
benefit, preserves the generating progress bar UX).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:49:53 +05:00
Admin
0e0a70a786 feat: listening settings panel + compact player style
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m30s
Release / Gitea Release (push) Successful in 49s
- AudioPlayer: add 'compact' playerStyle prop — slim seekable player with
  progress bar, skip ±15/30s, play/pause circle button, and speed cycle
- Chapter reader settings gear panel: new Listening section with player
  style picker (Standard/Compact), speed control (0.75–2×), auto-next
  toggle, and sleep timer — all persisted in reader_layout_v1 localStorage
- audioStore.speed/autoNext/sleep now accessible directly from settings
  panel without opening the audio player

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:34:41 +05:00
Admin
bdbe48ce1a fix: register MediaSession action handlers for iOS lock screen resume
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 56s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 3m13s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
Without explicit setActionHandler('play'/'pause'/...) the browser uses
default handling which on iOS Safari stops working after ~1 min of
pause in background/locked state (AudioSession gets suspended and the
lock screen button can't resume it without a direct user gesture).

Registering handlers in the layout (where audioEl lives) ensures:
- play/pause call audioEl directly — iOS treats this as a trusted
  gesture and allows .play() even from the lock screen
- seekbackward/seekforward map to ±15s / ±30s
- playbackState stays in sync so the lock screen shows the right icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:22:09 +05:00
Admin
87c541b178 fix: text-gen chapter-names truncation and bad title format
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m50s
Release / Docker / ui (push) Successful in 2m14s
Release / Gitea Release (push) Successful in 44s
- Default max_tokens to 4096 for chapter-names so large chapter lists
  are not cut off mid-JSON by the model's token limit
- Rewrite system prompt to clarify placeholder semantics ({n} = number,
  {scene} = scene hint) and explicitly forbid echoing the number inside
  the title field — prevents "Chapter 1 - 1: ..." style duplications
- UI: surface raw_response in the error area when chapters:[] is returned
  so the admin can see what the model actually produced
2026-04-04 13:43:29 +05:00
Admin
0b82d96798 feat: admin Text Gen tool — chapter names + book description via CF Workers AI
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m55s
Release / Docker / runner (push) Successful in 3m3s
Release / Docker / ui (push) Successful in 2m31s
Release / Gitea Release (push) Successful in 43s
Adds backend handlers and SvelteKit UI for an admin text generation tool.
The tool lets admins propose and apply AI-generated chapter titles and book
descriptions using Cloudflare Workers AI (12 LLM models, model selector shared
across both tabs).
2026-04-04 13:16:10 +05:00
Admin
a2dd0681d2 fix: pass CFAI_ACCOUNT_ID/CFAI_API_TOKEN into backend and runner containers
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Successful in 55s
Both vars were in Doppler but never forwarded via docker-compose.yml, causing
the image generation handler to return 503 "CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing".
2026-04-04 12:50:19 +05:00
Admin
ad50bd21ea chore: add .githooks/pre-commit to auto-recompile paraglide on JSON changes
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m47s
Release / Docker / runner (push) Successful in 2m41s
Release / Docker / ui (push) Successful in 2m7s
Release / Gitea Release (push) Successful in 39s
Committed hooks directory (.githooks/pre-commit) auto-runs `npm run paraglide`
and force-stages the generated JS output whenever ui/messages/*.json files are
staged, preventing svelte-check CI failures from stale paraglide output.

Added `just setup` recipe to configure core.hooksPath for new contributors.
2026-04-04 12:11:42 +05:00
Admin
6572e7c849 fix: cover_url bug, add save-cover endpoint, fix svelte-check CI failure
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 2m42s
Release / Docker / ui (push) Successful in 2m9s
Release / Gitea Release (push) Successful in 38s
- Fix handlers_image.go: cover_url now uses /api/cover/novelfire.net/{slug} (was 'local')
- Add POST /api/admin/image-gen/save-cover: persists pre-generated base64 directly to
  MinIO via PutCover without re-calling Cloudflare AI
- Add SvelteKit proxy route api/admin/image-gen/save-cover/+server.ts
- Update UI saveAsCover(): send existing image_b64 to save-cover instead of re-generating
- Run npm run paraglide to compile admin_nav_image_gen message; force-add gitignored files
- svelte-check: 0 errors, 21 warnings (all pre-existing)
2026-04-04 12:05:19 +05:00
Admin
74ece7e94e feat: reading view modes — progress bar, paginated, spacing, width, focus
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 3m3s
Release / Gitea Release (push) Has been skipped
Five new reader settings (persisted in localStorage as reader_layout_v1):

- Read mode: Scroll (default) vs Pages — paginated mode splits content
  into viewport-height pages, navigate with tap left/right, arrow keys,
  or Prev/Next buttons. Recalculates on content change.

- Line spacing: Tight (1.55) / Normal (1.85) / Loose (2.2) via
  --reading-line-height CSS var on :root.

- Reading width: Narrow (58ch) / Normal (72ch) / Wide (90ch) via
  --reading-max-width CSS var on :root.

- Paragraph style: Spaced (default) vs Indented (text-indent: 2em,
  tight margin — book-like feel).

- Focus mode: hides audio player, language switcher, bottom nav and
  comments so only the text remains.

Scroll progress bar: thin 2px brand-colored bar fixed at top of
viewport in scroll mode, fills as you read through the chapter.

All options added to the floating settings gear panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:44 +05:00
Admin
d1b7d3e36c feat: admin image generation via Cloudflare Workers AI
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 3m3s
Release / Docker / runner (push) Successful in 2m57s
Release / Gitea Release (push) Has been skipped
- Add cfai/image.go: ImageGenClient with GenerateImage, GenerateImageFromReference, AllImageModels (9 models)
- Add handlers_image.go: GET /api/admin/image-gen/models + POST /api/admin/image-gen (JSON + multipart)
- Wire ImageGen client in main.go + server.go Dependencies
- Add admin/image-gen SvelteKit page: type toggle, model selector, prompt, reference img2img, advanced options, result panel, history, save-as-cover, download
- Add SvelteKit proxy route api/admin/image-gen forwarding to Go backend
- Add admin_nav_image_gen message key to all 5 locale files
- Add Image Gen nav link to admin layout
2026-04-04 11:46:22 +05:00
Admin
aaa008ac99 feat: add Cloudflare AI TTS engine (aura-2-en) with voice grouping in UI
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 41s
2026-04-04 11:12:55 +05:00
Admin
9806f0d894 feat: live Gitea changelog, Gitea sidebar link, release title/body extraction
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 21s
- Admin changelog page now fetches releases live from the public Gitea
  API (https://gitea.kalekber.cc) instead of a baked releases.json file
- Remove /static/releases.json from ui/.gitignore (no longer generated)
- Add Gitea to admin sidebar external tools (admin_nav_gitea i18n key,
  all 5 locales, compiled paraglide JS force-added)
- release.yaml: remove 'Fetch releases from Gitea API' step; extract
  release title and body from the tagged commit message for Gitea releases
2026-04-03 23:20:30 +05:00
Admin
e862135775 fix: commit missing paraglide feed message JS files (were gitignored, needed force-add)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m35s
Release / Docker / ui (push) Successful in 1m57s
Release / Gitea Release (push) Successful in 20s
New feed_*.js and nav_feed.js outputs from npm run paraglide were not tracked
because src/lib/paraglide/.gitignore ignores everything by default. Force-add
them so CI can run svelte-check without running paraglide compile first.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- PocketTTS: StreamAudioMP3 pipes live WAV response body through ffmpeg
  (streaming transcode — no full-buffer wait)
- Kokoro: StreamAudioMP3 uses stream:true mode, returning MP3 frames
  directly without the two-step download-link flow
- AudioStore: PutAudioStream added for multipart MinIO upload from reader
- WriteTimeout bumped 60s → 15min to accommodate full-chapter streams
- X-Accel-Buffering: no header disables Caddy/nginx response buffering
2026-03-30 22:02:36 +05:00
Admin
b0a4cb8b3d fix: remove spurious 'export * as m' from messages.js causing all pages to 500
Some checks failed
CI / Backend (push) Successful in 1m9s
CI / UI (push) Successful in 34s
Release / Test backend (push) Successful in 39s
Release / Docker / caddy (push) Successful in 46s
CI / Backend (pull_request) Successful in 43s
Release / Check ui (push) Successful in 1m21s
CI / UI (pull_request) Successful in 34s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Failing after 3m8s
Release / Docker / runner (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
The paraglide messages.js had an extra 'export * as m from ...' line which
caused Rollup/Vite to tree-shake all actual message function imports in the
SSR bundle. Every m.* call compiled to (void 0)(), crashing every page
server-side with TypeError. Removed the duplicate namespace re-export.
2026-03-30 21:23:37 +05:00
554 changed files with 32479 additions and 517 deletions

View File

@@ -2,11 +2,8 @@ name: CI
on:
push:
paths:
- "backend/**"
- "ui/**"
- ".gitea/workflows/ci.yaml"
pull_request:
tags-ignore:
- "v*"
paths:
- "backend/**"
- "ui/**"

View File

@@ -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
View 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
View File

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

View File

@@ -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
}
}

Binary file not shown.

View File

@@ -15,6 +15,7 @@ package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -25,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"
@@ -113,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 != "" {
@@ -133,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)
@@ -162,6 +191,10 @@ func run() error {
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
Log: log,
},
)
@@ -195,6 +228,14 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

View File

@@ -12,6 +12,7 @@ package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
@@ -22,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"
@@ -129,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 {
@@ -190,6 +201,7 @@ func run() error {
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Log: log,
}
@@ -222,6 +234,14 @@ func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/taskqueue"
@@ -14,13 +15,15 @@ import (
type Producer struct {
pb taskqueue.Producer // underlying PocketBase producer
client *asynq.Client
log *slog.Logger
}
// NewProducer wraps an existing PocketBase Producer with Asynq dispatch.
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt) *Producer {
func NewProducer(pb taskqueue.Producer, redisOpt asynq.RedisConnOpt, log *slog.Logger) *Producer {
return &Producer{
pb: pb,
client: asynq.NewClient(redisOpt),
log: log,
}
}
@@ -49,7 +52,9 @@ func (p *Producer) CreateScrapeTask(ctx context.Context, kind, targetURL string,
}
if err := p.enqueue(ctx, taskType, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.
return id, fmt.Errorf("asynq enqueue scrape (task still in PB): %w", err)
p.log.Warn("asynq enqueue scrape failed (task still in PB, runner will poll)",
"task_id", id, "err", err)
return id, nil
}
return id, nil
}
@@ -68,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)

View File

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

View File

@@ -8,7 +8,7 @@ package backend
// handleBrowse, handleSearch
// handleGetRanking, handleGetCover
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
// handleAudioGenerate, handleAudioStatus, handleAudioProxy
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
// handleVoices
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
// handlePresignAvatarUpload, handlePresignAvatar
@@ -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"
@@ -568,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
}
// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
// Removes duplicate chapters_idx records for a book, keeping the latest record
// per chapter number. Returns the number of duplicate records deleted.
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
if err != nil {
s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
"deleted": deleted,
})
return
}
s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
}
// ── Audio ──────────────────────────────────────────────────────────────────────
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
@@ -703,6 +728,182 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, presignURL, http.StatusFound)
}
// handleAudioStream handles GET /api/audio-stream/{slug}/{n}.
//
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
// (same as handleAudioProxy) — the client plays from storage immediately.
//
// Slow path (first request): streams audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, subsequent
// requests hit the fast path and skip TTS generation entirely.
//
// Query params:
//
// voice (optional, defaults to DefaultVoice)
// format (optional, "mp3" or "wav"; defaults to "mp3")
//
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
// raw WAV frames to the client with lower latency at the cost of larger files.
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
jsonError(w, http.StatusBadRequest, "invalid chapter")
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.cfg.DefaultVoice
}
format := r.URL.Query().Get("format")
if format != "wav" {
format = "mp3"
}
contentType := "audio/mpeg"
if format == "wav" {
contentType = "audio/wav"
}
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
// ── Fast path: already in MinIO ───────────────────────────────────────────
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 1*time.Hour)
if err != nil {
s.deps.Log.Error("handleAudioStream: PresignAudio failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusInternalServerError, "presign failed")
return
}
http.Redirect(w, r, presignURL, http.StatusFound)
return
}
// ── Slow path: generate + stream + save ───────────────────────────────────
// Read the chapter text.
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
if err != nil {
s.deps.Log.Error("handleAudioStream: ReadChapter failed", "slug", slug, "n", n, "err", err)
jsonError(w, http.StatusNotFound, "chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
return
}
// Open the TTS stream (WAV or MP3 depending on format param).
var audioStream io.ReadCloser
if format == "wav" {
if 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)
}
} else {
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)
}
}
if err != nil {
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
jsonError(w, http.StatusInternalServerError, "tts stream failed")
return
}
defer audioStream.Close()
// Tee: every byte read from audioStream is written to both the HTTP
// response and a pipe that feeds the MinIO upload goroutine.
pr, pw := io.Pipe()
// MinIO upload runs concurrently. Size -1 triggers multipart upload.
uploadDone := make(chan error, 1)
go func() {
uploadDone <- s.deps.AudioStore.PutAudioStream(
context.Background(), // use background — request ctx may cancel after client disconnects
audioKey, pr, -1, contentType,
)
}()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
tee := io.TeeReader(audioStream, pw)
buf := make([]byte, 32*1024)
for {
nr, readErr := tee.Read(buf)
if nr > 0 {
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
// Client disconnected — abort upload pipe so goroutine exits.
pw.CloseWithError(writeErr)
<-uploadDone
return
}
if canFlush {
flusher.Flush()
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
s.deps.Log.Warn("handleAudioStream: read error mid-stream", "err", readErr)
pw.CloseWithError(readErr)
<-uploadDone
return
}
}
// Signal end of stream to the MinIO upload goroutine.
pw.Close()
if uploadErr := <-uploadDone; uploadErr != nil {
s.deps.Log.Error("handleAudioStream: MinIO upload failed", "key", audioKey, "err", uploadErr)
// Audio was already streamed to the client — just log; don't error.
// The next request will re-stream since the object is absent.
}
// Note: we do not call FinishAudioTask here — the backend has no Consumer.
// handleAudioStatus fast-paths on AudioExists, so the UI will see "done"
// on its next poll as soon as the MinIO object is present.
}
// ── Translation ────────────────────────────────────────────────────────────────
// supportedTranslationLangs is the set of target locales the backend accepts.
@@ -948,6 +1149,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) {
@@ -1019,6 +1380,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) {
@@ -1028,7 +1392,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
@@ -1405,6 +1775,109 @@ func stripMarkdown(src string) string {
return strings.TrimSpace(src)
}
// ── EPUB export ───────────────────────────────────────────────────────────────
// handleExportEPUB handles GET /api/export/{slug}.
// Generates and streams an EPUB file for the book identified by slug.
// Optional query params: from=N&to=N to limit the chapter range (default: all).
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
// Parse optional from/to range.
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
fromN, toN := 0, 0
if fromStr != "" {
v, err := strconv.Atoi(fromStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
return
}
fromN = v
}
if toStr != "" {
v, err := strconv.Atoi(toStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
return
}
toN = v
}
// Fetch book metadata for title and author.
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
if err != nil || !inLib {
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
jsonError(w, http.StatusNotFound, "book not found")
return
}
// List all chapters.
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
if err != nil {
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
return
}
// Filter chapters by from/to range.
var filtered []epubChapter
for _, ch := range chapters {
if fromN > 0 && ch.Number < fromN {
continue
}
if toN > 0 && ch.Number > toN {
continue
}
// Fetch markdown from MinIO.
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
if readErr != nil {
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
// Skip chapters that cannot be fetched.
continue
}
// Convert markdown to HTML using goldmark.
md := goldmark.New()
var htmlBuf bytes.Buffer
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
htmlBuf.Reset()
htmlBuf.WriteString("<p>" + mdText + "</p>")
}
filtered = append(filtered, epubChapter{
Number: ch.Number,
Title: ch.Title,
HTML: htmlBuf.String(),
})
}
if len(filtered) == 0 {
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
return
}
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
if err != nil {
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
return
}
w.Header().Set("Content-Type", "application/epub+zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
w.WriteHeader(http.StatusOK)
w.Write(epubBytes)
}
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when

View File

@@ -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.01.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"
}

View File

@@ -0,0 +1,486 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// chapterNamesBatchSize is the number of chapters sent per LLM request.
// Keeps output well within the 4096-token response limit (~30 tokens/title).
const chapterNamesBatchSize = 100
// 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"`
}
// 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"`
}
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
TotalBatches int `json:"total_batches"`
// ChaptersDone is the cumulative count of chapters processed so far.
ChaptersDone int `json:"chapters_done"`
// TotalChapters is the total chapter count for this book.
TotalChapters int `json:"total_chapters"`
// Model is the CF AI model used.
Model string `json:"model"`
// Chapters contains the proposed titles for this batch.
Chapters []proposedChapterTitle `json:"chapters"`
// Error is non-empty if this batch failed.
Error string `json:"error,omitempty"`
// Done is true on the final sentinel event (no Chapters).
Done bool `json:"done,omitempty"`
}
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
// to the LLM sequentially, and streams results back as Server-Sent Events so
// the frontend can show live progress. Each SSE data line is a JSON-encoded
// chapterNamesBatchEvent. The final event has Done=true.
//
// 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
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
// Partition chapters into batches.
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters),
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
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 (25 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.`
// Switch to SSE before writing anything.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt chapterNamesBatchEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
chaptersDone := 0
for i, batch := range batches {
if r.Context().Err() != nil {
return // client disconnected
}
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
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 batch failed",
"batch", i+1, "err", genErr)
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
})
continue
}
proposed := parseChapterTitlesJSON(raw)
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
})
}
// Final sentinel event.
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
}
// chunkChapters splits a chapter slice into batches of at most size n.
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
var batches [][]domain.ChapterInfo
for len(chapters) > 0 {
end := n
if end > len(chapters) {
end = len(chapters)
}
batches = append(batches, chapters[:end])
chapters = chapters[end:]
}
return batches
}
// 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 24 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})
}

View File

@@ -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
}
@@ -161,6 +174,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/audio/{slug}/{n}", s.handleAudioGenerate)
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
// Streaming audio: serves from MinIO if cached, else streams live TTS
// while simultaneously uploading to MinIO for future requests.
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
@@ -171,6 +187,26 @@ 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)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -182,6 +218,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)
@@ -199,7 +238,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
Addr: s.cfg.Addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
WriteTimeout: 15 * time.Minute, // audio-stream can take several minutes for a full chapter
IdleTimeout: 60 * time.Second,
}
@@ -327,6 +366,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()

View File

@@ -14,6 +14,7 @@ package bookstore
import (
"context"
"io"
"time"
"github.com/libnovel/backend/internal/domain"
@@ -34,6 +35,11 @@ type BookWriter interface {
// ChapterExists returns true if the markdown object for ref already exists.
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
// DeduplicateChapters removes duplicate chapters_idx records for slug,
// keeping only one record per chapter number (the one with the latest
// updated timestamp). Returns the number of duplicate records deleted.
DeduplicateChapters(ctx context.Context, slug string) (int, error)
}
// BookReader is the read side used by the backend to serve content.
@@ -79,14 +85,24 @@ type RankingStore interface {
// AudioStore covers audio object storage (runner writes; backend reads).
type AudioStore interface {
// AudioObjectKey returns the MinIO object key for a cached audio file.
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
// Format: {slug}/{n}/{voice}.mp3
AudioObjectKey(slug string, n int, voice string) string
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
// with a custom extension (e.g. "mp3" or "wav").
AudioObjectKeyExt(slug string, n int, voice, ext string) string
// AudioExists returns true when the audio object is present in MinIO.
AudioExists(ctx context.Context, key string) bool
// PutAudio stores raw audio bytes under the given MinIO object key.
PutAudio(ctx context.Context, key string, data []byte) error
// PutAudioStream uploads audio from r to MinIO under key.
// size must be the exact byte length of r, or -1 to use multipart upload.
// contentType should be "audio/mpeg" or "audio/wav".
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
}
// PresignStore generates short-lived URLs — used exclusively by the backend.

View File

@@ -2,6 +2,7 @@ package bookstore_test
import (
"context"
"io"
"testing"
"time"
@@ -38,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
// RankingStore
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
@@ -51,9 +53,13 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}
// PresignStore
func (m *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Duration) (string, error) {

View File

@@ -0,0 +1,315 @@
// 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.
//
// The aura-2-en model enforces a hard 2 000-character limit per request.
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
// and concatenates the resulting MP3 frames.
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.
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
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"
}
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
var combined []byte
for _, chunk := range chunks {
part, err := c.generateChunk(ctx, chunk, speaker)
if err != nil {
return nil, err
}
combined = append(combined, part...)
}
return combined, nil
}
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
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
}
// splitText splits src into chunks of at most maxChars characters each.
// It tries to break at paragraph boundaries first, then at sentence-ending
// punctuation (. ! ?), and falls back to the nearest space.
func splitText(src string, maxChars int) []string {
if len(src) <= maxChars {
return []string{src}
}
var chunks []string
remaining := src
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Search window: the first maxChars bytes of remaining.
// Use byte length here because the API limit is in bytes/chars for ASCII;
// for safety we operate on rune-aware slices.
window := remaining
if len(window) > maxChars {
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
window = runeSlice(remaining, maxChars)
}
cut := -1
// 1. Prefer paragraph break (\n\n or \n).
if i := strings.LastIndex(window, "\n\n"); i > 0 {
cut = i + 2
} else if i := strings.LastIndex(window, "\n"); i > 0 {
cut = i + 1
}
// 2. Fall back to sentence-ending punctuation followed by a space.
if cut < 0 {
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
if i := strings.LastIndex(window, punct); i > 0 {
candidate := i + len(punct)
if cut < 0 || candidate > cut {
cut = candidate
}
}
}
}
// 3. Last resort: nearest space.
if cut < 0 {
if i := strings.LastIndex(window, " "); i > 0 {
cut = i + 1
}
}
// 4. Hard cut at maxChars runes if no boundary found.
if cut < 0 {
cut = len(window)
}
chunk := strings.TrimSpace(remaining[:cut])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = remaining[cut:]
}
return chunks
}
// runeSlice returns the first n runes of s as a string.
func runeSlice(s string, n int) string {
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}
// 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
}

View 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: 120.
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
}

View 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()
}

View File

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

View File

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

View File

@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
return nil
}
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
s.mu.Lock()
defer s.mu.Unlock()

View File

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

View File

@@ -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 {

View File

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

View File

@@ -1,8 +1,10 @@
package runner_test
import (
"bytes"
"context"
"errors"
"io"
"sync/atomic"
"testing"
"time"
@@ -92,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
return false
}
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
return 0, nil
}
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
type stubBookReader struct {
text string
@@ -124,11 +130,18 @@ type stubAudioStore struct {
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
}
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
}
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
s.putCalled.Add(1)
return s.putErr
}
func (s *stubAudioStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
s.putCalled.Add(1)
return s.putErr
}
// stubNovelScraper satisfies scraper.NovelScraper minimally.
type stubNovelScraper struct {
@@ -185,6 +198,22 @@ func (s *stubKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, erro
return s.data, s.genErr
}
func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
s.called.Add(1)
if s.genErr != nil {
return nil, s.genErr
}
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
s.called.Add(1)
if s.genErr != nil {
return nil, s.genErr
}
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
return []string{"af_bella"}, nil
}

View File

@@ -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.
@@ -155,6 +162,14 @@ func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType st
return err
}
// putObjectStream uploads from r with known size (or -1 for multipart).
func (m *minioClient) putObjectStream(ctx context.Context, bucket, key, contentType string, r io.Reader, size int64) error {
_, err := m.client.PutObject(ctx, bucket, key, r, size,
minio.PutObjectOptions{ContentType: contentType},
)
return err
}
func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) {
obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{})
if err != nil {

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"strings"
"time"
@@ -73,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)
}
@@ -117,7 +130,23 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
insertPayload := map[string]any{
"slug": slug,
"number": ref.Number,
"title": ref.Title,
"created": time.Now().UTC().Format(time.RFC3339),
}
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
if postErr == nil {
return nil
}
// POST failed — a concurrent writer may have inserted the same slug+number.
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil || len(items) == 0 {
return postErr // original POST error is more informative
}
}
var rec struct {
ID string `json:"id"`
@@ -126,6 +155,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
}
// DeduplicateChapters removes duplicate chapters_idx records for slug.
// For each chapter number that has more than one record, it keeps the record
// with the latest "updated" timestamp and deletes the rest.
// Returns the number of records deleted.
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
if err != nil {
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
}
type record struct {
ID string `json:"id"`
Number int `json:"number"`
Updated string `json:"updated"`
}
// Group records by chapter number.
byNumber := make(map[int][]record)
for _, raw := range items {
var rec record
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
continue
}
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
}
deleted := 0
for _, recs := range byNumber {
if len(recs) <= 1 {
continue
}
// Keep the record with the latest Updated timestamp; delete the rest.
keep := 0
for i := 1; i < len(recs); i++ {
if recs[i].Updated > recs[keep].Updated {
keep = i
}
}
for i, rec := range recs {
if i == keep {
continue
}
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
continue
}
deleted++
}
}
return deleted, nil
}
// ── BookReader ────────────────────────────────────────────────────────────────
type pbBook struct {
@@ -375,6 +457,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)
}
@@ -383,6 +469,10 @@ func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error {
return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data)
}
func (s *Store) PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
return s.mc.putObjectStream(ctx, s.mc.bucketAudio, key, contentType, r, size)
}
// ── PresignStore ──────────────────────────────────────────────────────────────
func (s *Store) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
@@ -569,6 +659,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) {

View File

@@ -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.

View File

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

Binary file not shown.

View File

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

View File

@@ -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
View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>500 — Internal Error — libnovel</title>
<meta http-equiv="refresh" content="20">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #f59e0b;
letter-spacing: -0.02em;
text-decoration: none;
}
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
gap: 0;
}
.illustration {
width: 96px;
height: 96px;
margin-bottom: 2rem;
}
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.btn {
display: inline-block;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.btn-secondary {
background: transparent;
color: #a1a1aa;
border: 1px solid #27272a;
cursor: pointer;
}
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">libnovel</a>
</header>
<main>
<!-- Book with lightning bolt SVG -->
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Book cover -->
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Spine -->
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Pages edge -->
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
<!-- Lightning bolt -->
<path d="M44 22 L34 46 H42 L36 70 L58 42 H48 L56 22 Z" fill="#f59e0b" opacity="0.9"/>
<!-- Text lines -->
<rect x="22" y="58" width="28" height="2.5" rx="1.25" fill="#3f3f46"/>
<rect x="22" y="63" width="18" height="2.5" rx="1.25" fill="#3f3f46"/>
<rect x="22" y="68" width="24" height="2.5" rx="1.25" fill="#3f3f46"/>
</svg>
<div class="watermark">500</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Internal error</span>
</div>
<h1>Something went wrong</h1>
<p>An unexpected error occurred on our end. We're on it — try again in a moment.</p>
<div class="actions">
<a class="btn" href="/">Go home</a>
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
</div>
<p class="refresh-note">Auto-refreshing in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -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"
@@ -285,6 +310,9 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
# Polar (subscriptions)
POLAR_API_TOKEN: "${POLAR_API_TOKEN}"
POLAR_WEBHOOK_SECRET: "${POLAR_WEBHOOK_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
@@ -382,12 +410,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 +447,7 @@ volumes:
pb_data:
meili_data:
valkey_data:
redis_data:
caddy_data:
caddy_config:
caddy_logs:

View File

@@ -58,6 +58,14 @@ services:
VALKEY_ADDR: ""
GODEBUG: "preferIPv4=1"
# ── LibreTranslate (internal Docker network) ──────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis ─────────────────────────────────────────────────────
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
KOKORO_URL: "http://kokoro-fastapi:8880"
KOKORO_VOICE: "${KOKORO_VOICE}"
@@ -67,6 +75,7 @@ services:
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
@@ -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:

View File

@@ -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,21 @@ 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
labels:
- "com.centurylinklabs.watchtower.enable=true"
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 +76,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 +111,5 @@ services:
retries: 3
volumes:
redis_data:
libretranslate_models:
libretranslate_db:

View File

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

View File

@@ -62,6 +62,39 @@ create() {
esac
}
# add_index COLLECTION INDEX_NAME SQL_EXPR
# Fetches current schema, adds index if absent by name, PATCHes collection.
add_index() {
COLL="$1"; INAME="$2"; ISQL="$3"
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
PARSED=$(echo "$SCHEMA" | python3 -c "
import sys, json
d = json.load(sys.stdin)
indexes = d.get('indexes', [])
exists = any('$INAME' in idx for idx in indexes)
print('exists=' + str(exists))
print('id=' + d.get('id', ''))
if not exists:
indexes.append('$ISQL')
print('indexes=' + json.dumps(indexes))
" 2>/dev/null)
if echo "$PARSED" | grep -q "^exists=True"; then
log "index exists (skip): $COLL.$INAME"; return
fi
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PATCH "$PB/api/collections/$COLL_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "{\"indexes\":${NEW_INDEXES}}")
case "$STATUS" in
200|201) log "added index: $COLL.$INAME" ;;
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
esac
}
# add_field COLLECTION FIELD_NAME FIELD_TYPE
# Fetches current schema, appends field if absent, PATCHes collection.
# Requires python3 for safe JSON manipulation.
@@ -116,9 +149,10 @@ create "books" '{
create "chapters_idx" '{
"name":"chapters_idx","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"number","type":"number", "required":true},
{"name":"title", "type":"text"}
{"name":"slug", "type":"text", "required":true},
{"name":"number", "type":"number", "required":true},
{"name":"title", "type":"text"},
{"name":"created", "type":"date"}
]}'
create "ranking" '{
@@ -190,14 +224,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 +294,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 +325,14 @@ 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"
add_field "chapters_idx" "created" "date"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
add_index "chapters_idx" "idx_chapters_idx_created" \
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
log "done"

3
ui/.gitignore vendored
View File

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

View File

@@ -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",
@@ -347,6 +366,26 @@
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
"subscribe_page_title": "Go Pro \u2014 libnovel",
"subscribe_heading": "Read more. Listen more.",
"subscribe_subheading": "Upgrade to Pro and unlock the full libnovel experience.",
"subscribe_monthly_label": "Monthly",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per month",
"subscribe_annual_label": "Annual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per year",
"subscribe_annual_save": "Save 33%",
"subscribe_cta_monthly": "Start monthly plan",
"subscribe_cta_annual": "Start annual plan",
"subscribe_already_pro": "You already have a Pro subscription.",
"subscribe_manage": "Manage subscription",
"subscribe_benefit_audio": "Unlimited audio chapters per day",
"subscribe_benefit_voices": "Voice selection across all TTS engines",
"subscribe_benefit_translation": "Read in French, Indonesian, Portuguese, and Russian",
"subscribe_benefit_downloads": "Download chapters for offline listening",
"subscribe_login_prompt": "Sign in to subscribe",
"subscribe_login_cta": "Sign in",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",
@@ -357,6 +396,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 +461,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"
}

View File

@@ -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",
@@ -347,6 +366,26 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
"subscribe_monthly_price": "6 $",
"subscribe_monthly_period": "par mois",
"subscribe_annual_label": "Annuel",
"subscribe_annual_price": "48 $",
"subscribe_annual_period": "par an",
"subscribe_annual_save": "Économisez 33 %",
"subscribe_cta_monthly": "Commencer le plan mensuel",
"subscribe_cta_annual": "Commencer le plan annuel",
"subscribe_already_pro": "Vous avez déjà un abonnement Pro.",
"subscribe_manage": "Gérer l'abonnement",
"subscribe_benefit_audio": "Chapitres audio illimités par jour",
"subscribe_benefit_voices": "Sélection de voix pour tous les moteurs TTS",
"subscribe_benefit_translation": "Lire en français, indonésien, portugais et russe",
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
@@ -357,6 +396,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 +459,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"
}

View File

@@ -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",
@@ -347,6 +366,26 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per bulan",
"subscribe_annual_label": "Tahunan",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per tahun",
"subscribe_annual_save": "Hemat 33%",
"subscribe_cta_monthly": "Mulai paket bulanan",
"subscribe_cta_annual": "Mulai paket tahunan",
"subscribe_already_pro": "Anda sudah berlangganan Pro.",
"subscribe_manage": "Kelola langganan",
"subscribe_benefit_audio": "Bab audio tak terbatas per hari",
"subscribe_benefit_voices": "Pilihan suara untuk semua mesin TTS",
"subscribe_benefit_translation": "Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia",
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
@@ -357,6 +396,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 +459,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"
}

View File

@@ -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",
@@ -347,6 +366,26 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "por mês",
"subscribe_annual_label": "Anual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "por ano",
"subscribe_annual_save": "Economize 33%",
"subscribe_cta_monthly": "Começar plano mensal",
"subscribe_cta_annual": "Começar plano anual",
"subscribe_already_pro": "Você já tem uma assinatura Pro.",
"subscribe_manage": "Gerenciar assinatura",
"subscribe_benefit_audio": "Capítulos de áudio ilimitados por dia",
"subscribe_benefit_voices": "Seleção de voz para todos os mecanismos TTS",
"subscribe_benefit_translation": "Leia em francês, indonésio, português e russo",
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
@@ -357,6 +396,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 +459,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"
}

View File

@@ -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": "Перепарсить книгу",
@@ -347,6 +366,26 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "в месяц",
"subscribe_annual_label": "Ежегодно",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "в год",
"subscribe_annual_save": "Сэкономьте 33%",
"subscribe_cta_monthly": "Начать месячный план",
"subscribe_cta_annual": "Начать годовой план",
"subscribe_already_pro": "У вас уже есть подписка Pro.",
"subscribe_manage": "Управление подпиской",
"subscribe_benefit_audio": "Неограниченные аудиоглавы в день",
"subscribe_benefit_voices": "Выбор голоса для всех TTS-движков",
"subscribe_benefit_translation": "Читайте на французском, индонезийском, португальском и русском",
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
@@ -357,6 +396,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 +459,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"
}

View File

@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && svelte-kit sync || echo ''",
"prepare": "svelte-kit sync || echo ''",
"paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && node -e \"const fs=require('fs'),f='./src/lib/paraglide/messages.js',c=fs.readFileSync(f,'utf8').split('\\n').filter(l=>!l.includes('export * as m')&&!l.includes('enabling auto-import')).join('\\n');fs.writeFileSync(f,c)\"",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},

View File

@@ -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
View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%sveltekit.status% — LibNovel</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: #09090b;
}
body {
min-height: 100svh;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, sans-serif;
color: #a1a1aa;
}
header {
padding: 1.5rem 2rem;
border-bottom: 1px solid #27272a;
}
.logo {
font-size: 1.125rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
text-decoration: none;
}
.logo span { color: #f59e0b; }
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
}
/* Inline SVG book illustration */
.illustration {
width: 96px;
height: 96px;
margin-bottom: 2rem;
opacity: 0.9;
}
.watermark {
font-size: clamp(5rem, 22vw, 9rem);
font-weight: 800;
color: #18181b;
line-height: 1;
letter-spacing: -0.04em;
user-select: none;
margin-bottom: 2rem;
}
.status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.75); }
}
.status-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #f59e0b;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #e4e4e7;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
p {
font-size: 0.9375rem;
max-width: 38ch;
line-height: 1.65;
margin-bottom: 2rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.btn {
display: inline-block;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
background: #f59e0b;
color: #000;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
transition: background 0.15s;
}
.btn:hover { background: #d97706; }
.btn-secondary {
background: transparent;
color: #a1a1aa;
border: 1px solid #27272a;
}
.btn-secondary:hover { background: #18181b; color: #e4e4e7; }
.refresh-note {
margin-top: 1.25rem;
font-size: 0.8rem;
color: #52525b;
}
#countdown { color: #71717a; }
footer {
padding: 1.5rem 2rem;
border-top: 1px solid #27272a;
text-align: center;
font-size: 0.8rem;
color: #3f3f46;
}
</style>
</head>
<body>
<header>
<a class="logo" href="/">Lib<span>Novel</span></a>
</header>
<main>
<!-- Book with broken spine SVG -->
<svg class="illustration" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Book cover -->
<rect x="14" y="12" width="50" height="68" rx="4" fill="#27272a" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Spine -->
<rect x="10" y="12" width="8" height="68" rx="2" fill="#18181b" stroke="#3f3f46" stroke-width="1.5"/>
<!-- Pages edge -->
<rect x="62" y="14" width="4" height="64" rx="1" fill="#1c1c1f"/>
<!-- Crack / broken lines -->
<path d="M22 38 L38 34 L34 48 L50 44" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Text lines (faded) -->
<rect x="22" y="24" width="28" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="30" width="22" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="56" width="28" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="62" width="18" height="3" rx="1.5" fill="#3f3f46"/>
<rect x="22" y="68" width="24" height="3" rx="1.5" fill="#3f3f46"/>
<!-- Exclamation dot -->
<circle cx="72" cy="22" r="10" fill="#18181b" stroke="#f59e0b" stroke-width="1.5"/>
<rect x="71" y="16" width="2" height="8" rx="1" fill="#f59e0b"/>
<rect x="71" y="26" width="2" height="2" rx="1" fill="#f59e0b"/>
</svg>
<div class="watermark">%sveltekit.status%</div>
<div class="status-row">
<div class="dot"></div>
<span class="status-label">Something went wrong</span>
</div>
<h1>The page couldn't load</h1>
<p>An unexpected error occurred. We're looking into it — try again in a moment.</p>
<div class="actions">
<a class="btn" href="/">Go home</a>
<button class="btn btn-secondary" onclick="location.reload()">Retry</button>
</div>
<p class="refresh-note">Auto-refreshing in <span id="countdown">20</span>s</p>
</main>
<footer>
&copy; LibNovel
</footer>
<script>
var s = 20;
var el = document.getElementById('countdown');
var t = setInterval(function () {
s--;
el.textContent = s;
if (s <= 0) { clearInterval(t); location.reload(); }
}, 1000);
</script>
</body>
</html>

View File

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

View File

@@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View 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)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Pages_LabelInputs */
const en_admin_pages_label = /** @type {(inputs: Admin_Pages_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Pages`)
};
const ru_admin_pages_label = /** @type {(inputs: Admin_Pages_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Страницы`)
};
const id_admin_pages_label = /** @type {(inputs: Admin_Pages_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Halaman`)
};
const pt_admin_pages_label = /** @type {(inputs: Admin_Pages_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Páginas`)
};
const fr_admin_pages_label = /** @type {(inputs: Admin_Pages_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Pages`)
};
/**
* | output |
* | --- |
* | "Pages" |
*
* @param {Admin_Pages_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_pages_label = /** @type {((inputs?: Admin_Pages_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Pages_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_pages_label(inputs)
if (locale === "ru") return ru_admin_pages_label(inputs)
if (locale === "id") return id_admin_pages_label(inputs)
if (locale === "pt") return pt_admin_pages_label(inputs)
return fr_admin_pages_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_BookInputs */
const en_admin_scrape_book = /** @type {(inputs: Admin_Scrape_BookInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape Book`)
};
const ru_admin_scrape_book = /** @type {(inputs: Admin_Scrape_BookInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Парсинг книги`)
};
const id_admin_scrape_book = /** @type {(inputs: Admin_Scrape_BookInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape Buku`)
};
const pt_admin_scrape_book = /** @type {(inputs: Admin_Scrape_BookInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extrair Livro`)
};
const fr_admin_scrape_book = /** @type {(inputs: Admin_Scrape_BookInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extraire un livre`)
};
/**
* | output |
* | --- |
* | "Scrape Book" |
*
* @param {Admin_Scrape_BookInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_book = /** @type {((inputs?: Admin_Scrape_BookInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_BookInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_book(inputs)
if (locale === "ru") return ru_admin_scrape_book(inputs)
if (locale === "id") return id_admin_scrape_book(inputs)
if (locale === "pt") return pt_admin_scrape_book(inputs)
return fr_admin_scrape_book(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_CancelInputs */
const en_admin_scrape_cancel = /** @type {(inputs: Admin_Scrape_CancelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cancel`)
};
const ru_admin_scrape_cancel = /** @type {(inputs: Admin_Scrape_CancelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отмена`)
};
const id_admin_scrape_cancel = /** @type {(inputs: Admin_Scrape_CancelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Batal`)
};
const pt_admin_scrape_cancel = /** @type {(inputs: Admin_Scrape_CancelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cancelar`)
};
const fr_admin_scrape_cancel = /** @type {(inputs: Admin_Scrape_CancelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annuler`)
};
/**
* | output |
* | --- |
* | "Cancel" |
*
* @param {Admin_Scrape_CancelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_cancel = /** @type {((inputs?: Admin_Scrape_CancelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_CancelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_cancel(inputs)
if (locale === "ru") return ru_admin_scrape_cancel(inputs)
if (locale === "id") return id_admin_scrape_cancel(inputs)
if (locale === "pt") return pt_admin_scrape_cancel(inputs)
return fr_admin_scrape_cancel(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_CatalogueInputs */
const en_admin_scrape_catalogue = /** @type {(inputs: Admin_Scrape_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape Catalogue`)
};
const ru_admin_scrape_catalogue = /** @type {(inputs: Admin_Scrape_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Парсинг каталога`)
};
const id_admin_scrape_catalogue = /** @type {(inputs: Admin_Scrape_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape Katalog`)
};
const pt_admin_scrape_catalogue = /** @type {(inputs: Admin_Scrape_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extrair Catálogo`)
};
const fr_admin_scrape_catalogue = /** @type {(inputs: Admin_Scrape_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extraire le catalogue`)
};
/**
* | output |
* | --- |
* | "Scrape Catalogue" |
*
* @param {Admin_Scrape_CatalogueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_catalogue = /** @type {((inputs?: Admin_Scrape_CatalogueInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_CatalogueInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_catalogue(inputs)
if (locale === "ru") return ru_admin_scrape_catalogue(inputs)
if (locale === "id") return id_admin_scrape_catalogue(inputs)
if (locale === "pt") return pt_admin_scrape_catalogue(inputs)
return fr_admin_scrape_catalogue(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_Filter_PlaceholderInputs */
const en_admin_scrape_filter_placeholder = /** @type {(inputs: Admin_Scrape_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter by kind, status or URL…`)
};
const ru_admin_scrape_filter_placeholder = /** @type {(inputs: Admin_Scrape_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Фильтр по типу, статусу или URL…`)
};
const id_admin_scrape_filter_placeholder = /** @type {(inputs: Admin_Scrape_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filter berdasarkan jenis, status, atau URL…`)
};
const pt_admin_scrape_filter_placeholder = /** @type {(inputs: Admin_Scrape_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrar por tipo, status ou URL…`)
};
const fr_admin_scrape_filter_placeholder = /** @type {(inputs: Admin_Scrape_Filter_PlaceholderInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Filtrer par type, statut ou URL…`)
};
/**
* | output |
* | --- |
* | "Filter by kind, status or URL…" |
*
* @param {Admin_Scrape_Filter_PlaceholderInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_filter_placeholder = /** @type {((inputs?: Admin_Scrape_Filter_PlaceholderInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_Filter_PlaceholderInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_filter_placeholder(inputs)
if (locale === "ru") return ru_admin_scrape_filter_placeholder(inputs)
if (locale === "id") return id_admin_scrape_filter_placeholder(inputs)
if (locale === "pt") return pt_admin_scrape_filter_placeholder(inputs)
return fr_admin_scrape_filter_placeholder(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_FromInputs */
const en_admin_scrape_from = /** @type {(inputs: Admin_Scrape_FromInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`From`)
};
const ru_admin_scrape_from = /** @type {(inputs: Admin_Scrape_FromInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`От`)
};
const id_admin_scrape_from = /** @type {(inputs: Admin_Scrape_FromInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Dari`)
};
const pt_admin_scrape_from = /** @type {(inputs: Admin_Scrape_FromInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`De`)
};
const fr_admin_scrape_from = /** @type {(inputs: Admin_Scrape_FromInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`De`)
};
/**
* | output |
* | --- |
* | "From" |
*
* @param {Admin_Scrape_FromInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_from = /** @type {((inputs?: Admin_Scrape_FromInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_FromInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_from(inputs)
if (locale === "ru") return ru_admin_scrape_from(inputs)
if (locale === "id") return id_admin_scrape_from(inputs)
if (locale === "pt") return pt_admin_scrape_from(inputs)
return fr_admin_scrape_from(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_Full_CatalogueInputs */
const en_admin_scrape_full_catalogue = /** @type {(inputs: Admin_Scrape_Full_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Full catalogue`)
};
const ru_admin_scrape_full_catalogue = /** @type {(inputs: Admin_Scrape_Full_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Полный каталог`)
};
const id_admin_scrape_full_catalogue = /** @type {(inputs: Admin_Scrape_Full_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Katalog penuh`)
};
const pt_admin_scrape_full_catalogue = /** @type {(inputs: Admin_Scrape_Full_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catálogo completo`)
};
const fr_admin_scrape_full_catalogue = /** @type {(inputs: Admin_Scrape_Full_CatalogueInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue complet`)
};
/**
* | output |
* | --- |
* | "Full catalogue" |
*
* @param {Admin_Scrape_Full_CatalogueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_full_catalogue = /** @type {((inputs?: Admin_Scrape_Full_CatalogueInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_Full_CatalogueInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_full_catalogue(inputs)
if (locale === "ru") return ru_admin_scrape_full_catalogue(inputs)
if (locale === "id") return id_admin_scrape_full_catalogue(inputs)
if (locale === "pt") return pt_admin_scrape_full_catalogue(inputs)
return fr_admin_scrape_full_catalogue(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_HeadingInputs */
const en_admin_scrape_heading = /** @type {(inputs: Admin_Scrape_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const ru_admin_scrape_heading = /** @type {(inputs: Admin_Scrape_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Парсинг`)
};
const id_admin_scrape_heading = /** @type {(inputs: Admin_Scrape_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const pt_admin_scrape_heading = /** @type {(inputs: Admin_Scrape_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extração`)
};
const fr_admin_scrape_heading = /** @type {(inputs: Admin_Scrape_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extraction`)
};
/**
* | output |
* | --- |
* | "Scrape" |
*
* @param {Admin_Scrape_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_heading = /** @type {((inputs?: Admin_Scrape_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_heading(inputs)
if (locale === "ru") return ru_admin_scrape_heading(inputs)
if (locale === "id") return id_admin_scrape_heading(inputs)
if (locale === "pt") return pt_admin_scrape_heading(inputs)
return fr_admin_scrape_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_No_MatchingInputs */
const en_admin_scrape_no_matching = /** @type {(inputs: Admin_Scrape_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`No matching tasks.`)
};
const ru_admin_scrape_no_matching = /** @type {(inputs: Admin_Scrape_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Задач не найдено.`)
};
const id_admin_scrape_no_matching = /** @type {(inputs: Admin_Scrape_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tidak ada tugas yang cocok.`)
};
const pt_admin_scrape_no_matching = /** @type {(inputs: Admin_Scrape_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nenhuma tarefa correspondente.`)
};
const fr_admin_scrape_no_matching = /** @type {(inputs: Admin_Scrape_No_MatchingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aucune tâche correspondante.`)
};
/**
* | output |
* | --- |
* | "No matching tasks." |
*
* @param {Admin_Scrape_No_MatchingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_no_matching = /** @type {((inputs?: Admin_Scrape_No_MatchingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_No_MatchingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_no_matching(inputs)
if (locale === "ru") return ru_admin_scrape_no_matching(inputs)
if (locale === "id") return id_admin_scrape_no_matching(inputs)
if (locale === "pt") return pt_admin_scrape_no_matching(inputs)
return fr_admin_scrape_no_matching(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_Page_TitleInputs */
const en_admin_scrape_page_title = /** @type {(inputs: Admin_Scrape_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape — Admin`)
};
const ru_admin_scrape_page_title = /** @type {(inputs: Admin_Scrape_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Парсинг — Админ`)
};
const id_admin_scrape_page_title = /** @type {(inputs: Admin_Scrape_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape — Admin`)
};
const pt_admin_scrape_page_title = /** @type {(inputs: Admin_Scrape_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extração — Admin`)
};
const fr_admin_scrape_page_title = /** @type {(inputs: Admin_Scrape_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Extraction — Admin`)
};
/**
* | output |
* | --- |
* | "Scrape — Admin" |
*
* @param {Admin_Scrape_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_page_title = /** @type {((inputs?: Admin_Scrape_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_page_title(inputs)
if (locale === "ru") return ru_admin_scrape_page_title(inputs)
if (locale === "id") return id_admin_scrape_page_title(inputs)
if (locale === "pt") return pt_admin_scrape_page_title(inputs)
return fr_admin_scrape_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_QueuingInputs */
const en_admin_scrape_queuing = /** @type {(inputs: Admin_Scrape_QueuingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Queuing…`)
};
const ru_admin_scrape_queuing = /** @type {(inputs: Admin_Scrape_QueuingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`В очереди…`)
};
const id_admin_scrape_queuing = /** @type {(inputs: Admin_Scrape_QueuingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mengantri…`)
};
const pt_admin_scrape_queuing = /** @type {(inputs: Admin_Scrape_QueuingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Na fila…`)
};
const fr_admin_scrape_queuing = /** @type {(inputs: Admin_Scrape_QueuingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`En file d'attente…`)
};
/**
* | output |
* | --- |
* | "Queuing…" |
*
* @param {Admin_Scrape_QueuingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_queuing = /** @type {((inputs?: Admin_Scrape_QueuingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_QueuingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_queuing(inputs)
if (locale === "ru") return ru_admin_scrape_queuing(inputs)
if (locale === "id") return id_admin_scrape_queuing(inputs)
if (locale === "pt") return pt_admin_scrape_queuing(inputs)
return fr_admin_scrape_queuing(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_Quick_GenresInputs */
const en_admin_scrape_quick_genres = /** @type {(inputs: Admin_Scrape_Quick_GenresInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Quick genres`)
};
const ru_admin_scrape_quick_genres = /** @type {(inputs: Admin_Scrape_Quick_GenresInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Быстрые жанры`)
};
const id_admin_scrape_quick_genres = /** @type {(inputs: Admin_Scrape_Quick_GenresInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Genre cepat`)
};
const pt_admin_scrape_quick_genres = /** @type {(inputs: Admin_Scrape_Quick_GenresInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gêneros rápidos`)
};
const fr_admin_scrape_quick_genres = /** @type {(inputs: Admin_Scrape_Quick_GenresInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Genres rapides`)
};
/**
* | output |
* | --- |
* | "Quick genres" |
*
* @param {Admin_Scrape_Quick_GenresInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_quick_genres = /** @type {((inputs?: Admin_Scrape_Quick_GenresInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_Quick_GenresInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_quick_genres(inputs)
if (locale === "ru") return ru_admin_scrape_quick_genres(inputs)
if (locale === "id") return id_admin_scrape_quick_genres(inputs)
if (locale === "pt") return pt_admin_scrape_quick_genres(inputs)
return fr_admin_scrape_quick_genres(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Scrape_RangeInputs */
const en_admin_scrape_range = /** @type {(inputs: Admin_Scrape_RangeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapter range`)
};
const ru_admin_scrape_range = /** @type {(inputs: Admin_Scrape_RangeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Диапазон глав`)
};
const id_admin_scrape_range = /** @type {(inputs: Admin_Scrape_RangeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Rentang bab`)
};
const pt_admin_scrape_range = /** @type {(inputs: Admin_Scrape_RangeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Intervalo de capítulos`)
};
const fr_admin_scrape_range = /** @type {(inputs: Admin_Scrape_RangeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Plage de chapitres`)
};
/**
* | output |
* | --- |
* | "Chapter range" |
*
* @param {Admin_Scrape_RangeInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_scrape_range = /** @type {((inputs?: Admin_Scrape_RangeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Scrape_RangeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_scrape_range(inputs)
if (locale === "ru") return ru_admin_scrape_range(inputs)
if (locale === "id") return id_admin_scrape_range(inputs)
if (locale === "pt") return pt_admin_scrape_range(inputs)
return fr_admin_scrape_range(inputs)
});

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