Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a771405db8 | ||
|
|
1e9a96aa0f | ||
|
|
23ae1ed500 | ||
|
|
e7cb460f9b | ||
|
|
392248e8a6 | ||
|
|
68ea2d2808 | ||
|
|
7b1df9b592 | ||
|
|
f4089fe111 |
@@ -200,6 +200,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
|
||||
}
|
||||
|
||||
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -93,6 +93,12 @@ func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
return p.pb.CancelTask(ctx, id)
|
||||
}
|
||||
|
||||
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
|
||||
// audio tasks for slug.
|
||||
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
return p.pb.CancelAudioTasksBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
// enqueue serialises payload and dispatches it to Asynq.
|
||||
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
|
||||
b, err := json.Marshal(payload)
|
||||
|
||||
@@ -708,12 +708,17 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
|
||||
// (same as handleAudioProxy) — the client plays from storage immediately.
|
||||
//
|
||||
// Slow path (first request): streams MP3 audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, any
|
||||
// pending audio_jobs task for this key is marked done. Subsequent requests hit
|
||||
// the fast path and skip TTS generation entirely.
|
||||
// Slow path (first request): streams audio directly to the client while
|
||||
// simultaneously uploading it to MinIO. After the stream completes, subsequent
|
||||
// requests hit the fast path and skip TTS generation entirely.
|
||||
//
|
||||
// Query params: voice (optional, defaults to DefaultVoice)
|
||||
// Query params:
|
||||
//
|
||||
// voice (optional, defaults to DefaultVoice)
|
||||
// format (optional, "mp3" or "wav"; defaults to "mp3")
|
||||
//
|
||||
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
|
||||
// raw WAV frames to the client with lower latency at the cost of larger files.
|
||||
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
@@ -727,7 +732,17 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
|
||||
format := r.URL.Query().Get("format")
|
||||
if format != "wav" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
|
||||
|
||||
// ── Fast path: already in MinIO ───────────────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
@@ -756,23 +771,39 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Open the TTS stream.
|
||||
// Open the TTS stream (WAV or MP3 depending on format param).
|
||||
var audioStream io.ReadCloser
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
if format == "wav" {
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
|
||||
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
@@ -787,11 +818,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
go func() {
|
||||
uploadDone <- s.deps.AudioStore.PutAudioStream(
|
||||
context.Background(), // use background — request ctx may cancel after client disconnects
|
||||
audioKey, pr, -1, "audio/mpeg",
|
||||
audioKey, pr, -1, contentType,
|
||||
)
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1081,6 +1112,166 @@ func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Reque
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin Audio ────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
|
||||
// Returns all audio jobs, optionally filtered by slug (?slug=...).
|
||||
// Sorted by started descending.
|
||||
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional slug filter.
|
||||
slugFilter := r.URL.Query().Get("slug")
|
||||
|
||||
type jobRow struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Voice string `json:"voice"`
|
||||
Status string `json:"status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
rows := make([]jobRow, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if slugFilter != "" && t.Slug != slugFilter {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, jobRow{
|
||||
ID: t.ID,
|
||||
CacheKey: t.CacheKey,
|
||||
Slug: t.Slug,
|
||||
Chapter: t.Chapter,
|
||||
Voice: t.Voice,
|
||||
Status: string(t.Status),
|
||||
WorkerID: t.WorkerID,
|
||||
ErrorMessage: t.ErrorMessage,
|
||||
Started: t.Started.Format(time.RFC3339),
|
||||
Finished: t.Finished.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
|
||||
}
|
||||
|
||||
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
|
||||
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
|
||||
//
|
||||
// Enqueues one audio task per chapter in [from, to].
|
||||
// skip_existing (default true): skip chapters already cached in MinIO — use this
|
||||
// to resume a previously interrupted bulk job.
|
||||
// force: if true, enqueue even when a pending/running task already exists.
|
||||
// Max 1000 chapters per request.
|
||||
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Voice string `json:"voice"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if body.Voice == "" {
|
||||
body.Voice = s.cfg.DefaultVoice
|
||||
}
|
||||
if body.From < 1 || body.To < body.From {
|
||||
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
|
||||
return
|
||||
}
|
||||
if body.To-body.From > 999 {
|
||||
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
|
||||
return
|
||||
}
|
||||
|
||||
// skip_existing defaults to true (resume-friendly).
|
||||
skipExisting := true
|
||||
if body.SkipExisting != nil {
|
||||
skipExisting = *body.SkipExisting
|
||||
}
|
||||
|
||||
var taskIDs []string
|
||||
skipped := 0
|
||||
|
||||
for n := body.From; n <= body.To; n++ {
|
||||
// Skip chapters already cached in MinIO.
|
||||
if skipExisting {
|
||||
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Skip chapters with an active (pending/running) task unless force=true.
|
||||
if !body.Force {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
|
||||
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
|
||||
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
|
||||
"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
|
||||
return
|
||||
}
|
||||
taskIDs = append(taskIDs, id)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"enqueued": len(taskIDs),
|
||||
"skipped": skipped,
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
|
||||
// Body: {"slug": "..."}
|
||||
// Cancels all pending and running audio tasks for the given slug.
|
||||
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
|
||||
"slug", body.Slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -174,6 +174,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -80,9 +80,14 @@ type RankingStore interface {
|
||||
|
||||
// AudioStore covers audio object storage (runner writes; backend reads).
|
||||
type AudioStore interface {
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
AudioObjectKey(slug string, n int, voice string) string
|
||||
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
AudioObjectKeyExt(slug string, n int, voice, ext string) string
|
||||
|
||||
// AudioExists returns true when the audio object is present in MinIO.
|
||||
AudioExists(ctx context.Context, key string) bool
|
||||
|
||||
@@ -91,7 +96,7 @@ type AudioStore interface {
|
||||
|
||||
// PutAudioStream uploads audio from r to MinIO under key.
|
||||
// size must be the exact byte length of r, or -1 to use multipart upload.
|
||||
// contentType should be "audio/mpeg".
|
||||
// contentType should be "audio/mpeg" or "audio/wav".
|
||||
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
||||
}
|
||||
|
||||
// AudioStore
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ type Client interface {
|
||||
// waiting for the full output. The caller must always close the ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
|
||||
// response_format:"wav". The caller must always close the ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available voice IDs. Falls back to an empty slice
|
||||
// on error — callers should treat an empty list as "service unavailable".
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
@@ -167,6 +172,47 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
|
||||
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("kokoro: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "af_bella"
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(map[string]any{
|
||||
"model": "kokoro",
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
"speed": 1.0,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: marshal wav stream request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: build wav stream request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kokoro: wav stream request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("kokoro: wav stream returned %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
|
||||
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
|
||||
@@ -59,6 +59,12 @@ type Client interface {
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
|
||||
// raw WAV audio directly from pocket-tts without any transcoding.
|
||||
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
|
||||
|
||||
// ListVoices returns the available predefined voice names.
|
||||
ListVoices(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -160,6 +166,25 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
|
||||
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
|
||||
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
|
||||
// The caller must always close the returned ReadCloser.
|
||||
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("pockettts: empty text")
|
||||
}
|
||||
if voice == "" {
|
||||
voice = "alba"
|
||||
}
|
||||
|
||||
resp, err := c.postTTS(ctx, text, voice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// ListVoices returns the statically known predefined voice names.
|
||||
// pocket-tts has no REST endpoint for listing voices.
|
||||
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
|
||||
|
||||
@@ -126,6 +126,9 @@ type stubAudioStore struct {
|
||||
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
|
||||
}
|
||||
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
|
||||
}
|
||||
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
|
||||
s.putCalled.Add(1)
|
||||
@@ -199,6 +202,14 @@ func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
|
||||
s.called.Add(1)
|
||||
if s.genErr != nil {
|
||||
return nil, s.genErr
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(s.data)), nil
|
||||
}
|
||||
|
||||
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
|
||||
return []string{"af_bella"}, nil
|
||||
}
|
||||
|
||||
@@ -109,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
|
||||
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached audio file.
|
||||
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
|
||||
// with a custom extension (e.g. "mp3" or "wav").
|
||||
// Format: {slug}/{n}/{voice}.{ext}
|
||||
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
|
||||
}
|
||||
|
||||
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
|
||||
// Format: {slug}/{n}/{voice}.mp3
|
||||
func AudioObjectKey(slug string, n int, voice string) string {
|
||||
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
|
||||
return AudioObjectKeyExt(slug, n, voice, "mp3")
|
||||
}
|
||||
|
||||
// AvatarObjectKey returns the MinIO object key for a user avatar image.
|
||||
|
||||
@@ -376,6 +376,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
|
||||
return AudioObjectKey(slug, n, voice)
|
||||
}
|
||||
|
||||
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
|
||||
return AudioObjectKeyExt(slug, n, voice, ext)
|
||||
}
|
||||
|
||||
func (s *Store) AudioExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
|
||||
}
|
||||
@@ -574,6 +578,28 @@ func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
|
||||
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
|
||||
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
|
||||
}
|
||||
cancelled := 0
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if patchErr := s.pb.patch(ctx,
|
||||
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
}
|
||||
return cancelled, nil
|
||||
}
|
||||
|
||||
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {
|
||||
|
||||
@@ -36,6 +36,10 @@ type Producer interface {
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
|
||||
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
|
||||
// Returns the number of tasks cancelled.
|
||||
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
|
||||
}
|
||||
|
||||
// Consumer is the read/claim side of the task queue used by the runner.
|
||||
|
||||
@@ -26,7 +26,8 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- libretranslate_db:/app/db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
|
||||
test: ["CMD", "python3", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/languages'); sys.exit(0)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
@@ -147,6 +147,15 @@ html {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE / Edge legacy */
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
@@ -9,12 +9,16 @@
|
||||
* Product IDs (Polar dashboard):
|
||||
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
|
||||
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
|
||||
*
|
||||
* Webhook event data shapes (Polar v1 API):
|
||||
* subscription.* → data.customer_id, data.product_id, data.status, data.customer.email
|
||||
* order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
|
||||
export const POLAR_PRO_PRODUCT_IDS = new Set([
|
||||
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
|
||||
@@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo
|
||||
|
||||
// ─── Subscription event handler ───────────────────────────────────────────────
|
||||
|
||||
interface PolarCustomer {
|
||||
email?: string;
|
||||
external_id?: string; // our app_users.id if set on the customer
|
||||
}
|
||||
|
||||
interface PolarSubscription {
|
||||
id: string;
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | ...
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
customer_email?: string;
|
||||
user_id?: string; // Polar user id (not our user id)
|
||||
customer?: PolarCustomer; // nested object — email lives here
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app_user for a Polar customer.
|
||||
* Priority: polar_customer_id → email → customer.external_id (our user ID)
|
||||
*/
|
||||
async function resolveUser(customer_id: string, customer?: PolarCustomer) {
|
||||
const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase');
|
||||
|
||||
// 1. By stored polar_customer_id (fastest on repeat events)
|
||||
const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (byCustomerId) return byCustomerId;
|
||||
|
||||
// 2. By email (most common first-time path)
|
||||
const email = customer?.email;
|
||||
if (email) {
|
||||
const byEmail = await getUserByEmail(email).catch(() => null);
|
||||
if (byEmail) return byEmail;
|
||||
}
|
||||
|
||||
// 3. By external_id = our user ID (if set via Polar API on customer creation)
|
||||
const externalId = customer?.external_id;
|
||||
if (externalId) {
|
||||
const byId = await getUserById(externalId).catch(() => null);
|
||||
if (byId) return byId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Polar subscription event.
|
||||
* Finds the matching app_user by email and updates role + polar fields.
|
||||
* Finds the matching app_user and updates role + polar fields.
|
||||
*/
|
||||
export async function handleSubscriptionEvent(
|
||||
eventType: string,
|
||||
subscription: PolarSubscription
|
||||
): Promise<void> {
|
||||
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
|
||||
const { id: subId, status, product_id, customer_id, customer } = subscription;
|
||||
|
||||
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
|
||||
log.info('polar', 'subscription event', {
|
||||
eventType, subId, status, product_id,
|
||||
customer_email: customer?.email
|
||||
});
|
||||
|
||||
if (!customer_email) {
|
||||
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by their polar_customer_id first (faster on repeat events), then by email
|
||||
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (!user) {
|
||||
const { getUserByEmail } = await import('$lib/server/pocketbase');
|
||||
user = await getUserByEmail(customer_email).catch(() => null);
|
||||
}
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
|
||||
if (!user) {
|
||||
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
|
||||
log.warn('polar', 'no app_user found for polar customer', {
|
||||
customer_email: customer?.email,
|
||||
customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,5 +135,60 @@ export async function handleSubscriptionEvent(
|
||||
polar_subscription_id: isActive ? subId : ''
|
||||
});
|
||||
|
||||
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
|
||||
log.info('polar', 'user role updated', {
|
||||
userId: user.id, username: user.username, newRole, status
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Order event handler ──────────────────────────────────────────────────────
|
||||
|
||||
interface PolarOrder {
|
||||
id: string;
|
||||
status: string;
|
||||
billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update"
|
||||
product_id: string | null;
|
||||
customer_id: string;
|
||||
subscription_id: string | null;
|
||||
customer?: PolarCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order.created — used for initial subscription purchases.
|
||||
* We only act on subscription_create billing_reason to avoid double-processing
|
||||
* (subscription.active will also fire, but this ensures we catch edge cases).
|
||||
*/
|
||||
export async function handleOrderCreated(order: PolarOrder): Promise<void> {
|
||||
const { id: orderId, billing_reason, product_id, customer_id, customer } = order;
|
||||
|
||||
log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email });
|
||||
|
||||
// Only handle new subscription purchases here; renewals are handled by subscription.updated
|
||||
if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') {
|
||||
log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) {
|
||||
log.debug('polar', 'order.created — product not a pro product', { product_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await resolveUser(customer_id, customer);
|
||||
if (!user) {
|
||||
log.warn('polar', 'order.created — no app_user found', {
|
||||
customer_email: customer?.email, customer_id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only upgrade if not already pro/admin — subscription.active will do a full sync too
|
||||
if (user.role !== 'pro' && user.role !== 'admin') {
|
||||
await patchUser(user.id, {
|
||||
role: 'pro',
|
||||
polar_customer_id: customer_id
|
||||
});
|
||||
log.info('polar', 'order.created — user upgraded to pro', {
|
||||
userId: user.id, username: user.username
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
8
ui/src/routes/admin/+layout.server.ts
Normal file
8
ui/src/routes/admin/+layout.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
};
|
||||
6
ui/src/routes/admin/+page.server.ts
Normal file
6
ui/src/routes/admin/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(302, '/admin/scrape');
|
||||
};
|
||||
106
ui/src/routes/api/checkout/+server.ts
Normal file
106
ui/src/routes/api/checkout/+server.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||
|
||||
const POLAR_API_BASE = 'https://api.polar.sh';
|
||||
|
||||
const PRICE_IDS: Record<string, string> = {
|
||||
monthly: '9c0eea36-4f4a-4fd6-970b-d176588d4771',
|
||||
annual: '5a5be04e-f252-4a30-8f8b-858b40ec33e4'
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/checkout
|
||||
* Body: { product: 'monthly' | 'annual' }
|
||||
*
|
||||
* Creates a Polar server-side checkout session with:
|
||||
* - external_customer_id = locals.user.id (so webhooks can match back to us)
|
||||
* - customer_email locked to the logged-in user's email (email field disabled in UI)
|
||||
* - allow_discount_codes: true
|
||||
* - success_url redirects to /profile?subscribed=1
|
||||
*
|
||||
* Returns: { url: string }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
const apiToken = env.POLAR_API_TOKEN;
|
||||
if (!apiToken) {
|
||||
log.error('checkout', 'POLAR_API_TOKEN not set');
|
||||
error(500, 'Checkout unavailable');
|
||||
}
|
||||
|
||||
let product: string;
|
||||
try {
|
||||
const body = await request.json() as { product?: unknown };
|
||||
product = String(body?.product ?? '');
|
||||
} catch {
|
||||
error(400, 'Invalid request body');
|
||||
}
|
||||
|
||||
const priceId = PRICE_IDS[product];
|
||||
if (!priceId) {
|
||||
error(400, `Unknown product: ${product}. Use 'monthly' or 'annual'.`);
|
||||
}
|
||||
|
||||
// Fetch the user's email from PocketBase (not in the auth token)
|
||||
let email: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
email = record?.email ?? null;
|
||||
} catch (e) {
|
||||
log.warn('checkout', 'failed to fetch user email (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Create a server-side checkout session on Polar
|
||||
// https://docs.polar.sh/api-reference/checkouts/create
|
||||
const payload = {
|
||||
product_price_id: priceId,
|
||||
allow_discount_codes: true,
|
||||
success_url: 'https://libnovel.cc/profile?subscribed=1',
|
||||
customer_external_id: locals.user.id,
|
||||
...(email ? { customer_email: email } : {})
|
||||
};
|
||||
|
||||
log.info('checkout', 'creating polar checkout session', {
|
||||
userId: locals.user.id,
|
||||
product,
|
||||
email: email ?? '(none)'
|
||||
});
|
||||
|
||||
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('checkout', 'polar checkout creation failed', {
|
||||
status: res.status,
|
||||
body: text.slice(0, 500)
|
||||
});
|
||||
error(502, 'Failed to create checkout session');
|
||||
}
|
||||
|
||||
const data = await res.json() as { url?: string; id?: string };
|
||||
const checkoutUrl = data?.url;
|
||||
|
||||
if (!checkoutUrl) {
|
||||
log.error('checkout', 'polar response missing url', { data: JSON.stringify(data).slice(0, 200) });
|
||||
error(502, 'Invalid checkout response from Polar');
|
||||
}
|
||||
|
||||
log.info('checkout', 'checkout session created', {
|
||||
userId: locals.user.id,
|
||||
checkoutId: data?.id,
|
||||
product
|
||||
});
|
||||
|
||||
return json({ url: checkoutUrl });
|
||||
};
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent, handleOrderCreated } from '$lib/server/polar';
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/polar
|
||||
*
|
||||
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
|
||||
* Signature is verified via HMAC-SHA256 before any processing.
|
||||
*
|
||||
* Handled events:
|
||||
* subscription.created — new subscription (status may be "active" or "trialing")
|
||||
* subscription.active — subscription became active (e.g. after payment)
|
||||
* subscription.updated — catch-all: cancellations, renewals, plan changes
|
||||
* subscription.canceled — cancel_at_period_end=true, still active until period end
|
||||
* subscription.revoked — access ended, downgrade to free
|
||||
* order.created — purchase / subscription_create: fast-path upgrade
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const rawBody = await request.text();
|
||||
@@ -30,14 +38,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'subscription.created':
|
||||
case 'subscription.active':
|
||||
case 'subscription.updated':
|
||||
case 'subscription.canceled':
|
||||
case 'subscription.revoked':
|
||||
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
break;
|
||||
|
||||
case 'order.created':
|
||||
// One-time purchases — no role change needed for now
|
||||
log.info('polar', 'order.created (no action)', { orderId: data.id });
|
||||
await handleOrderCreated(data as unknown as Parameters<typeof handleOrderCreated>[0]);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -10,24 +10,31 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
let email: string | null = null;
|
||||
let polarCustomerId: string | null = null;
|
||||
|
||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
||||
email = record?.email ?? null;
|
||||
polarCustomerId = record?.polar_customer_id ?? null;
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
try {
|
||||
sessions = await listUserSessions(locals.user.id);
|
||||
} catch (e) {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
email,
|
||||
polarCustomerId,
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
|
||||
@@ -4,12 +4,46 @@
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/state';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Polar checkout ───────────────────────────────────────────────────────────
|
||||
// Customer portal: always link to the org portal
|
||||
const manageUrl = `https://polar.sh/libnovel/portal`;
|
||||
|
||||
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
|
||||
let checkoutError = $state('');
|
||||
|
||||
async function startCheckout(product: 'monthly' | 'annual') {
|
||||
checkoutLoading = product;
|
||||
checkoutError = '';
|
||||
try {
|
||||
const res = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json() as { url: string };
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
checkoutError = 'Network error. Please try again.';
|
||||
} finally {
|
||||
checkoutLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
// Show a welcome banner when Polar redirects back with ?subscribed=1
|
||||
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
|
||||
|
||||
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
@@ -218,6 +252,17 @@
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-6 pb-12">
|
||||
|
||||
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
|
||||
{#if justSubscribed}
|
||||
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-5 pt-2">
|
||||
<div class="relative shrink-0">
|
||||
@@ -287,17 +332,34 @@
|
||||
<div class="mt-5 pt-5 border-t border-(--color-border)">
|
||||
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
|
||||
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
|
||||
{#if checkoutError}
|
||||
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('monthly')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'monthly'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
|
||||
{/if}
|
||||
{m.profile_upgrade_monthly()}
|
||||
</a>
|
||||
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
</a>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startCheckout('annual')}
|
||||
disabled={checkoutLoading !== null}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
|
||||
{#if checkoutLoading === 'annual'}
|
||||
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
|
||||
{:else}
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -307,7 +369,7 @@
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
|
||||
</div>
|
||||
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
|
||||
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
|
||||
{m.profile_manage_subscription()}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user