feat(player): CF AI preview/swap + fix PB token expiry + local build time
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m44s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m56s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m14s
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 1m44s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m42s
Release / Docker / runner (push) Successful in 2m56s
Release / Upload source maps (push) Successful in 1m30s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 1m14s
- 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()
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user