Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
Store interface, implement on HybridStore+MinioClient, register
GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
chip components into shared ChipButton in CommonViews.swift
713 lines
23 KiB
Go
713 lines
23 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
|
|
//
|
|
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
|
//
|
|
// The handler is non-blocking: it creates an audio_jobs record in PocketBase
|
|
// with status="pending", then fires a background goroutine to call Kokoro.
|
|
// The caller should poll GET /api/audio/status/{slug}/{n} to track progress.
|
|
//
|
|
// If audio is already cached (audio_cache hit) the handler returns
|
|
// status=200 with the proxy URL immediately — no job is created.
|
|
//
|
|
// Concurrent requests for the same key are deduplicated via audioJobIDs:
|
|
// the second caller gets a 202 with the existing job_id immediately.
|
|
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 {
|
|
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse optional voice from JSON body.
|
|
voice := s.kokoroVoice
|
|
var body struct {
|
|
Voice string `json:"voice"`
|
|
MaxChars int `json:"max_chars"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
}
|
|
if body.Voice != "" {
|
|
voice = body.Voice
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
|
|
|
// Fast path: already generated (check persistent store first).
|
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
|
return
|
|
}
|
|
|
|
// Deduplicate concurrent generation for the same key.
|
|
// If a goroutine is already running for this key, return the existing job_id.
|
|
s.audioMu.Lock()
|
|
if jobID, ok := s.audioJobIDs[cacheKey]; ok {
|
|
s.audioMu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"})
|
|
return
|
|
}
|
|
|
|
// Create the PocketBase job record.
|
|
jobID, createErr := s.store.CreateAudioJob(r.Context(), slug, n, voice)
|
|
if createErr != nil {
|
|
s.audioMu.Unlock()
|
|
s.log.Warn("audio: failed to create job record", "slug", slug, "chapter", n, "err", createErr)
|
|
// Non-fatal: still proceed, just won't have a persistent job record.
|
|
jobID = ""
|
|
}
|
|
|
|
s.audioJobIDs[cacheKey] = jobID
|
|
s.audioMu.Unlock()
|
|
|
|
// Fire background goroutine — request context must NOT be used here since
|
|
// the handler returns immediately.
|
|
maxChars := body.MaxChars
|
|
go func() {
|
|
defer func() {
|
|
s.audioMu.Lock()
|
|
delete(s.audioJobIDs, cacheKey)
|
|
s.audioMu.Unlock()
|
|
}()
|
|
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
s.runAudioGeneration(bgCtx, jobID, slug, n, voice, maxChars, cacheKey)
|
|
}()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "pending"})
|
|
}
|
|
|
|
// runAudioGeneration performs the actual Kokoro TTS work in a goroutine.
|
|
// It updates the audio_jobs record as it progresses and writes to audio_cache
|
|
// and MinIO on success.
|
|
func (s *Server) runAudioGeneration(ctx context.Context, jobID, slug string, n int, voice string, maxChars int, cacheKey string) {
|
|
markFailed := func(msg string) {
|
|
if jobID == "" {
|
|
return
|
|
}
|
|
if err := s.store.UpdateAudioJob(ctx, jobID, "failed", msg, time.Now()); err != nil {
|
|
s.log.Warn("audio: failed to update job to failed", "job_id", jobID, "err", err)
|
|
}
|
|
}
|
|
|
|
// Transition to "generating".
|
|
if jobID != "" {
|
|
if err := s.store.UpdateAudioJob(ctx, jobID, "generating", "", time.Time{}); err != nil {
|
|
s.log.Warn("audio: failed to mark job generating", "job_id", jobID, "err", err)
|
|
}
|
|
}
|
|
|
|
// Load and validate chapter text.
|
|
raw, err := s.store.ReadChapter(ctx, slug, n)
|
|
if err != nil {
|
|
s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err)
|
|
markFailed("chapter not found")
|
|
return
|
|
}
|
|
text := stripMarkdown(raw)
|
|
if text == "" {
|
|
markFailed("chapter text is empty")
|
|
return
|
|
}
|
|
if maxChars > 0 && len([]rune(text)) > maxChars {
|
|
text = string([]rune(text)[:maxChars])
|
|
}
|
|
if s.kokoroURL == "" {
|
|
markFailed("kokoro not configured")
|
|
return
|
|
}
|
|
|
|
// Call Kokoro.
|
|
filename, err := s.generateSpeech(ctx, text, voice, 1.0)
|
|
if err != nil {
|
|
s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
|
markFailed(err.Error())
|
|
return
|
|
}
|
|
|
|
if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil {
|
|
s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err)
|
|
}
|
|
|
|
// Download from Kokoro and persist to MinIO.
|
|
minioKey := s.store.AudioObjectKey(slug, n, voice)
|
|
audioData, dlErr := s.downloadFromKokoro(ctx, filename)
|
|
if dlErr != nil {
|
|
s.log.Warn("audio: MinIO upload skipped: kokoro download failed",
|
|
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
|
|
} else if putErr := s.store.PutAudio(ctx, minioKey, audioData); putErr != nil {
|
|
s.log.Warn("audio: MinIO upload failed",
|
|
"slug", slug, "chapter", n, "key", minioKey, "err", putErr)
|
|
} else {
|
|
s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
|
|
}
|
|
|
|
// Mark job done.
|
|
if jobID != "" {
|
|
if err := s.store.UpdateAudioJob(ctx, jobID, "done", "", time.Now()); err != nil {
|
|
s.log.Warn("audio: failed to mark job done", "job_id", jobID, "err", err)
|
|
}
|
|
}
|
|
s.log.Info("audio: generation complete", "slug", slug, "chapter", n, "filename", filename)
|
|
}
|
|
|
|
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
|
|
// Returns the current generation status for the given chapter + voice.
|
|
//
|
|
// Query params: voice (optional, defaults to server default).
|
|
//
|
|
// Possible responses:
|
|
// - 200 {"status":"done","url":"/api/audio-proxy/..."} — audio ready
|
|
// - 200 {"status":"pending"|"generating","job_id":"..."} — in progress
|
|
// - 200 {"status":"idle"} — no job yet
|
|
// - 200 {"status":"failed","error":"..."} — last job failed
|
|
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 || slug == "" {
|
|
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
voice := r.URL.Query().Get("voice")
|
|
if voice == "" {
|
|
voice = s.kokoroVoice
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Fast path: audio already in audio_cache → done.
|
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
|
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "done",
|
|
"url": proxyURL,
|
|
"filename": filename,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check in-flight map for live job ID.
|
|
s.audioMu.Lock()
|
|
liveJobID, inFlight := s.audioJobIDs[cacheKey]
|
|
s.audioMu.Unlock()
|
|
|
|
if inFlight {
|
|
// Look up persistent record for richer status.
|
|
if job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey); ok {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"status": job.Status,
|
|
"job_id": liveJobID,
|
|
})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "generating",
|
|
"job_id": liveJobID,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Not in-flight: check persistent record for last known result.
|
|
job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey)
|
|
if !ok {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "idle"})
|
|
return
|
|
}
|
|
|
|
resp := map[string]string{
|
|
"status": job.Status,
|
|
"job_id": job.ID,
|
|
}
|
|
if job.Status == "failed" && job.ErrorMessage != "" {
|
|
resp["error"] = job.ErrorMessage
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
|
// and returns the filename from the X-Download-Path response header.
|
|
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
|
|
reqBody, _ := json.Marshal(map[string]interface{}{
|
|
"model": "kokoro",
|
|
"input": text,
|
|
"voice": voice,
|
|
"response_format": "mp3",
|
|
"speed": speed,
|
|
"stream": false,
|
|
"return_download_link": true,
|
|
})
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return "", fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("kokoro request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
// Drain body so the connection can be reused.
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
|
|
}
|
|
|
|
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
|
|
dlPath := resp.Header.Get("X-Download-Path")
|
|
if dlPath == "" {
|
|
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
|
|
}
|
|
|
|
// Extract just the filename from the path.
|
|
filename := dlPath
|
|
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
|
|
filename = dlPath[idx+1:]
|
|
}
|
|
if filename == "" {
|
|
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
|
|
}
|
|
return filename, nil
|
|
}
|
|
|
|
// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage
|
|
// using GET /v1/download/{filename} and returns the raw bytes.
|
|
func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) {
|
|
url := s.kokoroURL + "/v1/download/" + filename
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build download request: %w", err)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kokoro download request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read kokoro download body: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// writeAudioResponse writes the JSON response for an already-cached audio chapter.
|
|
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
|
|
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) {
|
|
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"url": proxyURL,
|
|
"filename": filename,
|
|
})
|
|
}
|
|
|
|
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
|
// It looks up the Kokoro download filename for this chapter (voice) and
|
|
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
|
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
voice := r.URL.Query().Get("voice")
|
|
if voice == "" {
|
|
voice = s.kokoroVoice
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
|
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
|
|
if !ok {
|
|
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
kokoroURL := s.kokoroURL + "/v1/download/" + filename
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
|
|
if err != nil {
|
|
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
http.Error(w, "kokoro download failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "audio/mpeg")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
|
w.Header().Set("Content-Length", cl)
|
|
}
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// ─── Presigned URL handlers ───────────────────────────────────────────────────
|
|
|
|
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
|
|
// Returns a short-lived presigned MinIO URL for the chapter markdown object.
|
|
// The SvelteKit server uses this to fetch chapter content server-side.
|
|
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 || slug == "" {
|
|
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute)
|
|
if err != nil {
|
|
s.log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
|
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
|
}
|
|
|
|
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
|
// Returns a presigned MinIO URL for the audio object (if it has been generated).
|
|
// Query params: voice (optional, defaults to server default).
|
|
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
|
slug := r.PathValue("slug")
|
|
n, err := strconv.Atoi(r.PathValue("n"))
|
|
if err != nil || n < 1 || slug == "" {
|
|
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
voice := r.URL.Query().Get("voice")
|
|
if voice == "" {
|
|
voice = s.kokoroVoice
|
|
}
|
|
|
|
key := s.store.AudioObjectKey(slug, n, voice)
|
|
|
|
// Return 404 when the object hasn't been uploaded yet — the client treats
|
|
// this as "audio not ready" and will either poll or trigger generation.
|
|
if !s.store.AudioExists(r.Context(), key) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
|
if err != nil {
|
|
s.log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
|
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
|
}
|
|
|
|
// ─── Voices API ───────────────────────────────────────────────────────────────
|
|
|
|
// handleVoices handles GET /api/voices.
|
|
// Returns the list of available Kokoro voices as JSON: {"voices": [...]}
|
|
func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) {
|
|
voices := s.voices()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices})
|
|
}
|
|
|
|
// ─── Voice sample generation ──────────────────────────────────────────────────
|
|
|
|
// voiceSampleText is the short passage used for voice sample previews.
|
|
const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation."
|
|
|
|
// voiceSampleKey returns the MinIO object key for a voice sample.
|
|
// Key: _voice-samples/{voice}.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)
|
|
}
|
|
|
|
// warmVoiceSamples runs at startup in a background goroutine.
|
|
// It generates a short audio sample for every available Kokoro voice that
|
|
// doesn't already have one in MinIO, so the UI voice selector has playable
|
|
// previews without requiring a manual trigger.
|
|
// It respects ctx cancellation and waits up to 30 s for Kokoro to become
|
|
// reachable before giving up.
|
|
func (s *Server) warmVoiceSamples(ctx context.Context) {
|
|
if s.kokoroURL == "" {
|
|
return
|
|
}
|
|
|
|
// Wait for Kokoro to be reachable (it may still be starting up).
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(3 * time.Second):
|
|
}
|
|
}
|
|
|
|
voices := s.voices()
|
|
s.log.Info("warming voice samples", "voices", len(voices))
|
|
|
|
generated, skipped, failed := 0, 0, 0
|
|
for _, voice := range voices {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
key := voiceSampleKey(voice)
|
|
if s.store.AudioExists(ctx, key) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
filename, err := s.generateSpeech(ctx, voiceSampleText, voice, 1.0)
|
|
if err != nil {
|
|
s.log.Warn("voice sample warmup: generation failed", "voice", voice, "err", err)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
audioData, err := s.downloadFromKokoro(ctx, filename)
|
|
if err != nil {
|
|
s.log.Warn("voice sample warmup: download failed", "voice", voice, "err", err)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
if err := s.store.PutAudio(ctx, key, audioData); err != nil {
|
|
s.log.Warn("voice sample warmup: upload failed", "voice", voice, "key", key, "err", err)
|
|
failed++
|
|
continue
|
|
}
|
|
|
|
s.log.Debug("voice sample warmed", "voice", voice)
|
|
generated++
|
|
}
|
|
|
|
s.log.Info("voice sample warmup complete",
|
|
"generated", generated, "skipped", skipped, "failed", failed)
|
|
}
|
|
|
|
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
|
|
// It generates short audio samples for each available voice and stores them
|
|
// in the audio MinIO bucket so the UI can play them during voice selection.
|
|
// Already-generated samples are skipped (idempotent).
|
|
// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset.
|
|
// Returns: {"generated": [...], "skipped": [...], "failed": [...]}
|
|
func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) {
|
|
if s.kokoroURL == "" {
|
|
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Parse optional voice list from body.
|
|
var body struct {
|
|
Voices []string `json:"voices"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
}
|
|
|
|
targetVoices := body.Voices
|
|
if len(targetVoices) == 0 {
|
|
targetVoices = s.voices()
|
|
}
|
|
|
|
type result struct {
|
|
Generated []string `json:"generated"`
|
|
Skipped []string `json:"skipped"`
|
|
Failed []string `json:"failed"`
|
|
}
|
|
var res result
|
|
|
|
for _, voice := range targetVoices {
|
|
key := voiceSampleKey(voice)
|
|
|
|
// Skip if already uploaded.
|
|
if s.store.AudioExists(r.Context(), key) {
|
|
res.Skipped = append(res.Skipped, voice)
|
|
s.log.Debug("voice sample already exists, skipping", "voice", voice)
|
|
continue
|
|
}
|
|
|
|
// Generate via Kokoro (speed 1.0 for samples).
|
|
filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0)
|
|
if err != nil {
|
|
s.log.Warn("voice sample generation failed", "voice", voice, "err", err)
|
|
res.Failed = append(res.Failed, voice)
|
|
continue
|
|
}
|
|
|
|
// Download from Kokoro and upload to MinIO.
|
|
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
|
if dlErr != nil {
|
|
s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr)
|
|
res.Failed = append(res.Failed, voice)
|
|
continue
|
|
}
|
|
|
|
if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil {
|
|
s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr)
|
|
res.Failed = append(res.Failed, voice)
|
|
continue
|
|
}
|
|
|
|
s.log.Info("voice sample generated", "voice", voice, "key", key)
|
|
res.Generated = append(res.Generated, voice)
|
|
}
|
|
|
|
if res.Generated == nil {
|
|
res.Generated = []string{}
|
|
}
|
|
if res.Skipped == nil {
|
|
res.Skipped = []string{}
|
|
}
|
|
if res.Failed == nil {
|
|
res.Failed = []string{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(res)
|
|
}
|
|
|
|
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
|
|
// Returns a presigned URL for the voice sample audio file stored in MinIO.
|
|
// Returns 404 if the sample has not been generated yet.
|
|
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
|
|
voice := r.PathValue("voice")
|
|
if voice == "" {
|
|
http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
key := voiceSampleKey(voice)
|
|
|
|
if !s.store.AudioExists(r.Context(), key) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
|
|
if err != nil {
|
|
s.log.Error("presign voice sample failed", "voice", voice, "err", err)
|
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
|
}
|
|
|
|
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
|
|
// Returns a short-lived presigned PUT URL for uploading an avatar image directly
|
|
// to MinIO, along with the object key to record in PocketBase after the upload.
|
|
// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg".
|
|
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.PathValue("userId")
|
|
if userID == "" {
|
|
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ext := r.URL.Query().Get("ext")
|
|
switch ext {
|
|
case "jpg", "jpeg":
|
|
ext = "jpg"
|
|
case "png":
|
|
ext = "png"
|
|
case "webp":
|
|
ext = "webp"
|
|
default:
|
|
ext = "jpg"
|
|
}
|
|
|
|
uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext)
|
|
if err != nil {
|
|
s.log.Error("presign avatar upload failed", "userId", userID, "err", err)
|
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"upload_url": uploadURL,
|
|
"key": key,
|
|
})
|
|
}
|
|
|
|
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
|
|
// Returns a presigned GET URL for a user's existing avatar, or 404 if none.
|
|
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.PathValue("userId")
|
|
if userID == "" {
|
|
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
url, found, err := s.store.PresignAvatarURL(r.Context(), userID)
|
|
if err != nil {
|
|
s.log.Error("presign avatar failed", "userId", userID, "err", err)
|
|
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !found {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
|
}
|