Add async audio generation: job tracking in PocketBase + UI polling
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
This commit is contained in:
Admin
2026-03-07 20:12:08 +05:00
parent 88644341d8
commit 89f0dfb113
9 changed files with 540 additions and 83 deletions

View File

@@ -396,6 +396,66 @@ func (h *HybridStore) ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
return tasks, nil
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
func (h *HybridStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) {
return h.pb.CreateAudioJob(ctx, slug, chapter, voice)
}
func (h *HybridStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error {
return h.pb.UpdateAudioJob(ctx, id, status, errMsg, finished)
}
func (h *HybridStore) GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error) {
rec, ok, err := h.pb.GetAudioJob(ctx, cacheKey)
if err != nil || !ok {
return AudioJob{}, ok, err
}
job := AudioJob{
ID: strVal(rec, "id"),
CacheKey: strVal(rec, "cache_key"),
Slug: strVal(rec, "slug"),
Chapter: int(floatVal(rec, "chapter")),
Voice: strVal(rec, "voice"),
Status: strVal(rec, "status"),
ErrorMessage: strVal(rec, "error_message"),
}
if ts, ok := rec["started"].(string); ok {
job.Started, _ = time.Parse(time.RFC3339, ts)
}
if ts, ok := rec["finished"].(string); ok && ts != "" {
job.Finished, _ = time.Parse(time.RFC3339, ts)
}
return job, true, nil
}
func (h *HybridStore) ListAudioJobs(ctx context.Context) ([]AudioJob, error) {
rows, err := h.pb.ListAudioJobs(ctx)
if err != nil {
return nil, err
}
jobs := make([]AudioJob, 0, len(rows))
for _, r := range rows {
job := AudioJob{
ID: strVal(r, "id"),
CacheKey: strVal(r, "cache_key"),
Slug: strVal(r, "slug"),
Chapter: int(floatVal(r, "chapter")),
Voice: strVal(r, "voice"),
Status: strVal(r, "status"),
ErrorMessage: strVal(r, "error_message"),
}
if ts, ok := r["started"].(string); ok {
job.Started, _ = time.Parse(time.RFC3339, ts)
}
if ts, ok := r["finished"].(string); ok && ts != "" {
job.Finished, _ = time.Parse(time.RFC3339, ts)
}
jobs = append(jobs, job)
}
return jobs, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
func recToBookMeta(rec map[string]interface{}) scraper.BookMeta {

View File

@@ -396,6 +396,20 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error {
{"name": "error_message", "type": "text"},
},
},
{
"name": "audio_jobs",
"type": "base",
"fields": []map[string]interface{}{
{"name": "cache_key", "type": "text", "required": true}, // "slug/chapter/voice"
{"name": "slug", "type": "text", "required": true},
{"name": "chapter", "type": "number"},
{"name": "voice", "type": "text"},
{"name": "status", "type": "text", "required": true}, // "pending" | "generating" | "done" | "failed"
{"name": "error_message", "type": "text"},
{"name": "started", "type": "date"},
{"name": "finished", "type": "date"},
},
},
{
"name": "user_sessions",
"type": "base",
@@ -798,6 +812,76 @@ func (s *PocketBaseStore) ListScrapingTasks(ctx context.Context) ([]map[string]i
return s.pb.listAll(ctx, "scraping_tasks", "", "-started")
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending".
func (s *PocketBaseStore) CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error) {
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, voice)
data := map[string]interface{}{
"cache_key": cacheKey,
"slug": slug,
"chapter": chapter,
"voice": voice,
"status": "pending",
"started": time.Now().UTC().Format(time.RFC3339),
}
resp, err := s.pb.do(ctx, http.MethodPost, "/api/collections/audio_jobs/records", data)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("pocketbase: CreateAudioJob: status %d: %s", resp.StatusCode, b)
}
var rec map[string]interface{}
if err := json.Unmarshal(b, &rec); err != nil {
return "", fmt.Errorf("pocketbase: CreateAudioJob: decode: %w", err)
}
id, _ := rec["id"].(string)
return id, nil
}
// UpdateAudioJob patches status, error_message, and optionally finished on an audio_jobs record.
func (s *PocketBaseStore) UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error {
data := map[string]interface{}{
"status": status,
"error_message": errMsg,
}
if !finished.IsZero() {
data["finished"] = finished.UTC().Format(time.RFC3339)
}
resp, err := s.pb.do(ctx, http.MethodPatch,
fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), data)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("pocketbase: UpdateAudioJob id=%s: status %d: %s", id, resp.StatusCode, b)
}
return nil
}
// GetAudioJob returns the most recent audio_jobs record for the given cache key.
func (s *PocketBaseStore) GetAudioJob(ctx context.Context, cacheKey string) (map[string]interface{}, bool, error) {
rec, err := s.pb.listOne(ctx, "audio_jobs",
fmt.Sprintf(`cache_key="%s"`, pbEsc(cacheKey)))
if err != nil {
return nil, false, err
}
if rec == nil {
return nil, false, nil
}
return rec, true, nil
}
// ListAudioJobs returns all audio_jobs sorted by started descending.
func (s *PocketBaseStore) ListAudioJobs(ctx context.Context) ([]map[string]interface{}, error) {
return s.pb.listAll(ctx, "audio_jobs", "", "-started")
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// pbEsc escapes a string for use in a PocketBase filter expression.

View File

@@ -30,6 +30,20 @@ type ReadingProgress struct {
UpdatedAt time.Time `json:"updated_at"`
}
// AudioJob represents a single audio-generation job record from the
// audio_jobs collection.
type AudioJob struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"` // "slug/chapter/voice"
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
Status string `json:"status"` // "pending" | "generating" | "done" | "failed"
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ScrapeTask represents a single scraping job record from the scraping_tasks
// collection.
type ScrapeTask struct {
@@ -177,4 +191,17 @@ type Store interface {
UpdateScrapeTask(ctx context.Context, id string, u ScrapeTaskUpdate) error
// ListScrapeTasks returns all tasks sorted by started descending.
ListScrapeTasks(ctx context.Context) ([]ScrapeTask, error)
// ── Audio jobs ─────────────────────────────────────────────────────────
// CreateAudioJob inserts a new audio_jobs record with status="pending"
// and returns the assigned ID.
CreateAudioJob(ctx context.Context, slug string, chapter int, voice string) (string, error)
// UpdateAudioJob patches an existing audio job record (status, error, finished).
UpdateAudioJob(ctx context.Context, id, status, errMsg string, finished time.Time) error
// GetAudioJob returns the most recent audio job for the given cache key,
// or (zero, false, nil) if none exists.
GetAudioJob(ctx context.Context, cacheKey string) (AudioJob, bool, error)
// ListAudioJobs returns all audio jobs sorted by started descending.
ListAudioJobs(ctx context.Context) ([]AudioJob, error)
}