fix(audio): remove speed from TTS/cache/MinIO keys; fix presigned URL host rewrite
- Speed is no longer part of Kokoro generation, in-memory cache keys, or MinIO object keys — audio is always generated at 1.0 and playback speed is applied client-side via audioEl.playbackRate - presignAudio() now calls rewriteHost() so chapter audio URLs use the public MinIO endpoint (same as presignVoiceSample already did) - docker-compose.yml: rename MINIO_PUBLIC_ENDPOINT → PUBLIC_MINIO_PUBLIC_URL for the ui service so SvelteKit's $env/dynamic/public picks it up
This commit is contained in:
@@ -175,7 +175,7 @@ services:
|
|||||||
POCKETBASE_URL: "http://pocketbase:8090"
|
POCKETBASE_URL: "http://pocketbase:8090"
|
||||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
|
||||||
ports:
|
ports:
|
||||||
- "${UI_PORT:-5252}:3000"
|
- "${UI_PORT:-5252}:3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -425,9 +425,12 @@ func (s *Server) handleChapterText(w http.ResponseWriter, r *http.Request) {
|
|||||||
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
|
// It calls Kokoro's POST /v1/audio/speech with return_download_link=true.
|
||||||
// Kokoro generates the audio, saves it to its own temp storage, and returns
|
// Kokoro generates the audio, saves it to its own temp storage, and returns
|
||||||
// the download filename in the X-Download-Path response header.
|
// the download filename in the X-Download-Path response header.
|
||||||
// We cache that filename (in memory, keyed by slug/chapter/voice/speed) and
|
// We cache that filename (in memory, keyed by slug/chapter/voice) and
|
||||||
// return a proxy URL that the browser sets as audio.src.
|
// return a proxy URL that the browser sets as audio.src.
|
||||||
//
|
//
|
||||||
|
// TTS is always generated at speed 1.0; playback speed is controlled
|
||||||
|
// client-side via the <audio> element's playbackRate.
|
||||||
|
//
|
||||||
// On a cache hit the proxy URL is returned immediately without re-generating.
|
// On a cache hit the proxy URL is returned immediately without re-generating.
|
||||||
// Concurrent requests for the same key are deduplicated.
|
// Concurrent requests for the same key are deduplicated.
|
||||||
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -438,12 +441,11 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse optional voice/speed from JSON body.
|
// Parse optional voice from JSON body. Speed is intentionally ignored —
|
||||||
|
// TTS is always generated at 1.0; playback speed is applied client-side.
|
||||||
voice := s.kokoroVoice
|
voice := s.kokoroVoice
|
||||||
speed := 1.0
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Voice string `json:"voice"`
|
Voice string `json:"voice"`
|
||||||
Speed float64 `json:"speed"`
|
|
||||||
MaxChars int `json:"max_chars"`
|
MaxChars int `json:"max_chars"`
|
||||||
}
|
}
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
@@ -452,15 +454,12 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if body.Voice != "" {
|
if body.Voice != "" {
|
||||||
voice = body.Voice
|
voice = body.Voice
|
||||||
}
|
}
|
||||||
if body.Speed > 0 {
|
|
||||||
speed = body.Speed
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||||
|
|
||||||
// Fast path: already generated (check persistent store first).
|
// Fast path: already generated (check persistent store first).
|
||||||
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
||||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +475,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
// Check store again after waiting.
|
// Check store again after waiting.
|
||||||
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
|
||||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"audio generation failed"}`, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -512,10 +511,10 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call Kokoro POST /v1/audio/speech with return_download_link=true.
|
// Call Kokoro POST /v1/audio/speech at speed 1.0.
|
||||||
// Kokoro saves the generated audio to its own temp storage and returns the
|
// Kokoro saves the generated audio to its own temp storage and returns the
|
||||||
// download path in the X-Download-Path response header.
|
// download path in the X-Download-Path response header.
|
||||||
filename, err := s.generateSpeech(r.Context(), text, voice, speed)
|
filename, err := s.generateSpeech(r.Context(), text, voice, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
s.log.Error("kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
|
||||||
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
|
http.Error(w, `{"error":"speech generation failed"}`, http.StatusBadGateway)
|
||||||
@@ -528,7 +527,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Download generated audio from Kokoro and persist to MinIO synchronously
|
// Download generated audio from Kokoro and persist to MinIO synchronously
|
||||||
// so that the presigned URL returned to the client is immediately valid.
|
// so that the presigned URL returned to the client is immediately valid.
|
||||||
minioKey := s.store.AudioObjectKey(slug, n, voice, speed)
|
minioKey := s.store.AudioObjectKey(slug, n, voice)
|
||||||
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
|
||||||
if dlErr != nil {
|
if dlErr != nil {
|
||||||
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
|
s.log.Warn("audio MinIO upload skipped: kokoro download failed",
|
||||||
@@ -541,7 +540,7 @@ func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
|
s.log.Info("audio generated", "slug", slug, "chapter", n, "filename", filename)
|
||||||
s.writeAudioResponse(w, slug, n, voice, speed, filename)
|
s.writeAudioResponse(w, slug, n, voice, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
|
||||||
@@ -618,8 +617,8 @@ func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byt
|
|||||||
|
|
||||||
// writeAudioResponse writes the JSON response for a generated audio chapter.
|
// writeAudioResponse writes the JSON response for a generated audio chapter.
|
||||||
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
|
// 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, speed float64, filename string) {
|
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&speed=%.1f", slug, n, voice, speed)
|
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"url": proxyURL,
|
"url": proxyURL,
|
||||||
@@ -628,7 +627,7 @@ func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
|
||||||
// It looks up the Kokoro download filename for this chapter (voice/speed) and
|
// 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.
|
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
|
||||||
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
slug := r.PathValue("slug")
|
slug := r.PathValue("slug")
|
||||||
@@ -641,15 +640,8 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
if voice == "" {
|
if voice == "" {
|
||||||
voice = s.kokoroVoice
|
voice = s.kokoroVoice
|
||||||
}
|
}
|
||||||
speedStr := r.URL.Query().Get("speed")
|
|
||||||
speed := 1.0
|
|
||||||
if speedStr != "" {
|
|
||||||
if v, err := strconv.ParseFloat(speedStr, 64); err == nil && v > 0 {
|
|
||||||
speed = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s/%d/%s/%.2f", slug, n, voice, speed)
|
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
|
||||||
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
|
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
http.Error(w, "audio not generated yet", http.StatusNotFound)
|
||||||
@@ -709,7 +701,7 @@ func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
|
||||||
// Returns a presigned MinIO URL for the audio object (if it has been generated).
|
// Returns a presigned MinIO URL for the audio object (if it has been generated).
|
||||||
// Query params: voice, speed (optional, defaults to server defaults).
|
// Query params: voice (optional, defaults to server default).
|
||||||
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
||||||
slug := r.PathValue("slug")
|
slug := r.PathValue("slug")
|
||||||
n, err := strconv.Atoi(r.PathValue("n"))
|
n, err := strconv.Atoi(r.PathValue("n"))
|
||||||
@@ -722,14 +714,8 @@ func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
|
|||||||
if voice == "" {
|
if voice == "" {
|
||||||
voice = s.kokoroVoice
|
voice = s.kokoroVoice
|
||||||
}
|
}
|
||||||
speed := 1.0
|
|
||||||
if sv := r.URL.Query().Get("speed"); sv != "" {
|
|
||||||
if v, err := strconv.ParseFloat(sv, 64); err == nil && v > 0 {
|
|
||||||
speed = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
key := s.store.AudioObjectKey(slug, n, voice, speed)
|
key := s.store.AudioObjectKey(slug, n, voice)
|
||||||
|
|
||||||
// Return 404 when the object hasn't been uploaded yet — the client treats
|
// 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.
|
// this as "audio not ready" and will either poll or trigger generation.
|
||||||
|
|||||||
@@ -287,8 +287,8 @@ func (h *HybridStore) DeleteProgress(ctx context.Context, sessionID, slug string
|
|||||||
|
|
||||||
// ─── AudioObjectKey ───────────────────────────────────────────────────────────
|
// ─── AudioObjectKey ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *HybridStore) AudioObjectKey(slug string, n int, voice string, speed float64) string {
|
func (h *HybridStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||||
return AudioObjectKey(slug, n, voice, speed)
|
return AudioObjectKey(slug, n, voice)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HybridStore) AudioExists(ctx context.Context, key string) bool {
|
func (h *HybridStore) AudioExists(ctx context.Context, key string) bool {
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ func (m *MinioClient) CountChapters(ctx context.Context, slug string) int {
|
|||||||
// ─── Audio objects ────────────────────────────────────────────────────────────
|
// ─── Audio objects ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// AudioObjectKey returns the MinIO key for a cached audio file.
|
// AudioObjectKey returns the MinIO key for a cached audio file.
|
||||||
// Key: {slug}/ch{n}-{voice}-{speed:.1f}.mp3
|
// Key: {slug}/ch{n}-{voice}.mp3
|
||||||
func AudioObjectKey(slug string, n int, voice string, speed float64) string {
|
func AudioObjectKey(slug string, n int, voice string) string {
|
||||||
safe := sanitiseVoice(voice)
|
safe := sanitiseVoice(voice)
|
||||||
return fmt.Sprintf("%s/ch%d-%s-%.1f.mp3", slug, n, safe, speed)
|
return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PutAudio stores an audio file in the audio bucket.
|
// PutAudio stores an audio file in the audio bucket.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ type Store interface {
|
|||||||
// ── Audio object paths (MinIO) ─────────────────────────────────────────
|
// ── Audio object paths (MinIO) ─────────────────────────────────────────
|
||||||
|
|
||||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||||
AudioObjectKey(slug string, n int, voice string, speed float64) string
|
AudioObjectKey(slug string, n int, voice string) string
|
||||||
// AudioExists returns true when the audio object is present in the bucket.
|
// AudioExists returns true when the audio object is present in the bucket.
|
||||||
AudioExists(ctx context.Context, key string) bool
|
AudioExists(ctx context.Context, key string) bool
|
||||||
|
|
||||||
|
|||||||
@@ -314,14 +314,12 @@
|
|||||||
async function tryPresign(
|
async function tryPresign(
|
||||||
targetSlug: string,
|
targetSlug: string,
|
||||||
targetChapter: number,
|
targetChapter: number,
|
||||||
targetVoice: string,
|
targetVoice: string
|
||||||
targetSpeed: number
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
slug: targetSlug,
|
slug: targetSlug,
|
||||||
n: String(targetChapter),
|
n: String(targetChapter),
|
||||||
voice: targetVoice,
|
voice: targetVoice
|
||||||
speed: String(targetSpeed)
|
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/presign/audio?${params}`);
|
const res = await fetch(`/api/presign/audio?${params}`);
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
@@ -337,7 +335,6 @@
|
|||||||
if (audioStore.nextStatus !== 'none') return; // already running or done
|
if (audioStore.nextStatus !== 'none') return; // already running or done
|
||||||
|
|
||||||
const voice = audioStore.voice;
|
const voice = audioStore.voice;
|
||||||
const speed = audioStore.speed;
|
|
||||||
|
|
||||||
audioStore.nextStatus = 'prefetching';
|
audioStore.nextStatus = 'prefetching';
|
||||||
audioStore.nextChapterPrefetched = nextChapter;
|
audioStore.nextChapterPrefetched = nextChapter;
|
||||||
@@ -345,7 +342,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fast path: already generated
|
// Fast path: already generated
|
||||||
const url = await tryPresign(slug, nextChapter, voice, speed);
|
const url = await tryPresign(slug, nextChapter, voice);
|
||||||
if (url) {
|
if (url) {
|
||||||
stopNextProgress();
|
stopNextProgress();
|
||||||
audioStore.nextProgress = 100;
|
audioStore.nextProgress = 100;
|
||||||
@@ -358,14 +355,14 @@
|
|||||||
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
|
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ voice, speed })
|
body: JSON.stringify({ voice })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
||||||
|
|
||||||
stopNextProgress();
|
stopNextProgress();
|
||||||
audioStore.nextProgress = 100;
|
audioStore.nextProgress = 100;
|
||||||
|
|
||||||
const url2 = await tryPresign(slug, nextChapter, voice, speed);
|
const url2 = await tryPresign(slug, nextChapter, voice);
|
||||||
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
|
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
|
||||||
|
|
||||||
audioStore.nextAudioUrl = url2;
|
audioStore.nextAudioUrl = url2;
|
||||||
@@ -402,7 +399,6 @@
|
|||||||
|
|
||||||
async function startPlayback() {
|
async function startPlayback() {
|
||||||
const voice = audioStore.voice;
|
const voice = audioStore.voice;
|
||||||
const speed = audioStore.speed;
|
|
||||||
|
|
||||||
// Populate store metadata so layout + mini-bar have track info.
|
// Populate store metadata so layout + mini-bar have track info.
|
||||||
audioStore.slug = slug;
|
audioStore.slug = slug;
|
||||||
@@ -435,7 +431,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fast path B: audio already in MinIO (presign check).
|
// Fast path B: audio already in MinIO (presign check).
|
||||||
const url = await tryPresign(slug, chapter, voice, speed);
|
const url = await tryPresign(slug, chapter, voice);
|
||||||
if (url) {
|
if (url) {
|
||||||
audioStore.audioUrl = url;
|
audioStore.audioUrl = url;
|
||||||
audioStore.status = 'ready';
|
audioStore.status = 'ready';
|
||||||
@@ -453,13 +449,13 @@
|
|||||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ voice, speed })
|
body: JSON.stringify({ voice })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||||
|
|
||||||
await finishProgress();
|
await finishProgress();
|
||||||
|
|
||||||
const url2 = await tryPresign(slug, chapter, voice, speed);
|
const url2 = await tryPresign(slug, chapter, voice);
|
||||||
if (!url2) throw new Error('Audio generated but presign returned 404');
|
if (!url2) throw new Error('Audio generated but presign returned 404');
|
||||||
audioStore.audioUrl = url2;
|
audioStore.audioUrl = url2;
|
||||||
audioStore.status = 'ready';
|
audioStore.status = 'ready';
|
||||||
|
|||||||
@@ -98,14 +98,12 @@ export async function presignVoiceSample(voice: string): Promise<string> {
|
|||||||
export async function presignAudio(
|
export async function presignAudio(
|
||||||
slug: string,
|
slug: string,
|
||||||
n: number,
|
n: number,
|
||||||
voice?: string,
|
voice?: string
|
||||||
speed?: number
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (voice) params.set('voice', voice);
|
if (voice) params.set('voice', voice);
|
||||||
if (speed) params.set('speed', String(speed));
|
|
||||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||||
log.debug('minio', 'presigning audio', { slug, n, voice, speed });
|
log.debug('minio', 'presigning audio', { slug, n, voice });
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
|
res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
|
||||||
@@ -126,7 +124,5 @@ export async function presignAudio(
|
|||||||
}
|
}
|
||||||
const data = (await res.json()) as { url: string };
|
const data = (await res.json()) as { url: string };
|
||||||
log.debug('minio', 'presign audio ok', { slug, n });
|
log.debug('minio', 'presign audio ok', { slug, n });
|
||||||
// The scraper now signs audio URLs with the public endpoint directly,
|
return rewriteHost(data.url);
|
||||||
// so no host rewrite is needed here.
|
|
||||||
return data.url;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
|||||||
* Proxies the audio generation request to the scraper's /api/audio endpoint.
|
* Proxies the audio generation request to the scraper's /api/audio endpoint.
|
||||||
* Keeps the scraper URL server-side — the browser never needs to know it.
|
* Keeps the scraper URL server-side — the browser never needs to know it.
|
||||||
*
|
*
|
||||||
* Body: { voice?: string, speed?: number }
|
* Body: { voice?: string }
|
||||||
* Response: { url: string, filename: string }
|
* Response: { url: string, filename: string }
|
||||||
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...&speed=...
|
* where `url` is a relative path to GET /api/audio/[slug]/[n]?voice=...
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params, request }) => {
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
const { slug, n } = params;
|
const { slug, n } = params;
|
||||||
@@ -21,7 +21,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
|||||||
error(400, 'Invalid slug or chapter number');
|
error(400, 'Invalid slug or chapter number');
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { voice?: string; speed?: number } = {};
|
let body: { voice?: string } = {};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -45,10 +45,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
|||||||
// The scraper returns a proxy URL pointing to /api/audio-proxy/... — we rewrite
|
// The scraper returns a proxy URL pointing to /api/audio-proxy/... — we rewrite
|
||||||
// it to our own /api/audio/[slug]/[n]?... so the browser never calls the scraper directly.
|
// it to our own /api/audio/[slug]/[n]?... so the browser never calls the scraper directly.
|
||||||
const voice = body.voice ?? '';
|
const voice = body.voice ?? '';
|
||||||
const speed = body.speed ?? 1.0;
|
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (voice) qs.set('voice', voice);
|
if (voice) qs.set('voice', voice);
|
||||||
qs.set('speed', String(speed));
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -60,7 +58,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/audio/[slug]/[n]?voice=...&speed=...
|
* GET /api/audio/[slug]/[n]?voice=...
|
||||||
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
|
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
|
||||||
* This is the URL the browser's <audio> element uses as its src.
|
* This is the URL the browser's <audio> element uses as its src.
|
||||||
*/
|
*/
|
||||||
@@ -72,10 +70,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const voice = url.searchParams.get('voice') ?? '';
|
const voice = url.searchParams.get('voice') ?? '';
|
||||||
const speed = url.searchParams.get('speed') ?? '1';
|
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (voice) qs.set('voice', voice);
|
if (voice) qs.set('voice', voice);
|
||||||
qs.set('speed', speed);
|
|
||||||
|
|
||||||
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
|
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { presignAudio } from '$lib/server/minio';
|
|||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/presign/audio?slug=...&n=...&voice=...&speed=...
|
* GET /api/presign/audio?slug=...&n=...&voice=...
|
||||||
* Returns a presigned MinIO URL for the audio file so the browser
|
* Returns a presigned MinIO URL for the audio file so the browser
|
||||||
* can stream it directly without going through the server.
|
* can stream it directly without going through the server.
|
||||||
* Returns 404 when the audio has not been generated yet.
|
* Returns 404 when the audio has not been generated yet.
|
||||||
@@ -13,14 +13,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const slug = url.searchParams.get('slug');
|
const slug = url.searchParams.get('slug');
|
||||||
const n = parseInt(url.searchParams.get('n') ?? '', 10);
|
const n = parseInt(url.searchParams.get('n') ?? '', 10);
|
||||||
const voice = url.searchParams.get('voice') ?? undefined;
|
const voice = url.searchParams.get('voice') ?? undefined;
|
||||||
const speed = parseFloat(url.searchParams.get('speed') ?? '1') || 1;
|
|
||||||
|
|
||||||
if (!slug || !n || n < 1) {
|
if (!slug || !n || n < 1) {
|
||||||
error(400, 'Missing slug or n');
|
error(400, 'Missing slug or n');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const presignedUrl = await presignAudio(slug, n, voice, speed);
|
const presignedUrl = await presignAudio(slug, n, voice);
|
||||||
return json({ url: presignedUrl });
|
return json({ url: presignedUrl });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = (e as { status?: number }).status;
|
const status = (e as { status?: number }).status;
|
||||||
|
|||||||
Reference in New Issue
Block a user