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
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user