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