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