From 1abb4cd71466576ef25d0d86691ff4a90b1740dd Mon Sep 17 00:00:00 2001
From: Admin
Date: Sun, 5 Apr 2026 22:12:22 +0500
Subject: [PATCH] feat(player): CF AI preview/swap + fix PB token expiry +
local build time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char
chunk via CF AI so playback starts immediately; full chapter cached in MinIO
- Frontend: replace CF AI spinner with preview blob URL + background swap to
full presigned URL when runner finishes, preserving currentTime
- AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap
- pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once
on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h)
- Footer build time now rendered in user's local timezone via toLocaleString()
---
backend/internal/backend/handlers.go | 109 +++++++++++++++++++++++
backend/internal/backend/server.go | 3 +
ui/src/lib/audio.svelte.ts | 7 ++
ui/src/lib/components/AudioPlayer.svelte | 106 +++++++++++++++++-----
ui/src/lib/server/pocketbase.ts | 20 ++++-
ui/src/routes/+layout.svelte | 20 ++++-
6 files changed, 236 insertions(+), 29 deletions(-)
diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go
index 789a788..7ad7509 100644
--- a/backend/internal/backend/handlers.go
+++ b/backend/internal/backend/handlers.go
@@ -904,6 +904,115 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
// on its next poll as soon as the MinIO object is present.
}
+// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}.
+//
+// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
+// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,
+// roughly 1-2 minutes of audio) so the client can start playing immediately
+// while the full audio is generated in the background by the runner.
+//
+// Fast path: if a preview object already exists in MinIO, redirects to its
+// presigned URL (no regeneration).
+//
+// Slow path: generates the first chunk via CF AI, streams the MP3 bytes to the
+// client, and simultaneously uploads to MinIO under a "_preview" key so future
+// requests hit the fast path.
+//
+// Only CF AI voices are expected here. Calling this with a Kokoro/PocketTTS
+// voice falls back to the normal audio-stream endpoint behaviour.
+//
+// Query params:
+//
+// voice (required — must be a cfai: voice)
+func (s *Server) handleAudioPreview(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
+ }
+
+ if s.deps.CFAI == nil {
+ jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
+ return
+ }
+
+ // Preview key: same as normal key with a "_preview" suffix before the extension.
+ // e.g. slug/1/cfai:luna_preview.mp3
+ previewKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice+"_preview", "mp3")
+
+ // ── Fast path: preview already in MinIO ──────────────────────────────────
+ if s.deps.AudioStore.AudioExists(r.Context(), previewKey) {
+ presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), previewKey, 1*time.Hour)
+ if err != nil {
+ s.deps.Log.Error("handleAudioPreview: 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 first chunk + stream + save ──────────────────────
+
+ // Read the chapter text.
+ raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
+ if err != nil {
+ s.deps.Log.Error("handleAudioPreview: 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
+ }
+
+ // Take only the first ~1 800 characters — one CF AI chunk, roughly 1-2 min.
+ const previewChars = 1800
+ firstChunk := text
+ if len([]rune(text)) > previewChars {
+ runes := []rune(text)
+ firstChunk = string(runes[:previewChars])
+ // Walk back to last sentence boundary (. ! ?) to avoid a mid-word cut.
+ for i := previewChars - 1; i > previewChars/2; i-- {
+ r := runes[i]
+ if r == '.' || r == '!' || r == '?' || r == '\n' {
+ firstChunk = string(runes[:i+1])
+ break
+ }
+ }
+ }
+
+ // Generate the preview chunk via CF AI.
+ mp3, err := s.deps.CFAI.GenerateAudio(r.Context(), firstChunk, voice)
+ if err != nil {
+ s.deps.Log.Error("handleAudioPreview: GenerateAudio failed", "slug", slug, "n", n, "voice", voice, "err", err)
+ jsonError(w, http.StatusInternalServerError, "tts generation failed")
+ return
+ }
+
+ // Upload to MinIO in the background so the next request hits the fast path.
+ go func() {
+ if uploadErr := s.deps.AudioStore.PutAudio(
+ context.Background(), previewKey, mp3,
+ ); uploadErr != nil {
+ s.deps.Log.Error("handleAudioPreview: MinIO upload failed", "key", previewKey, "err", uploadErr)
+ }
+ }()
+
+ w.Header().Set("Content-Type", "audio/mpeg")
+ w.Header().Set("Content-Length", strconv.Itoa(len(mp3)))
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mp3)
+}
+
// ── Translation ────────────────────────────────────────────────────────────────
// supportedTranslationLangs is the set of target locales the backend accepts.
diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go
index efab799..dc35b7f 100644
--- a/backend/internal/backend/server.go
+++ b/backend/internal/backend/server.go
@@ -180,6 +180,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// 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)
+ // CF AI preview: generates only the first ~1 800-char chunk so the client
+ // can start playing immediately while the full audio is generated by the runner.
+ mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)
// Translation task creation (backend creates task; runner executes via LibreTranslate)
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts
index c0da47a..bd28b61 100644
--- a/ui/src/lib/audio.svelte.ts
+++ b/ui/src/lib/audio.svelte.ts
@@ -62,6 +62,13 @@ class AudioStore {
/** Pseudo-progress bar value 0–100 during generation */
progress = $state(0);
+ /**
+ * True while playing a short CF AI preview clip (~1-2 min) and the full
+ * audio is still being generated in the background. Set to false once the
+ * full audio URL has been swapped in.
+ */
+ isPreview = $state(false);
+
// ── Playback state (kept in sync with the