From b4595d3f64f34a6c1f41dfba33eeed51b1a117e8 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 19 Apr 2026 21:44:35 +0500 Subject: [PATCH] feat(podcast): add podcast RSS feed generation and publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now generate TTS audiobooks chapter-by-chapter and publish them as standard RSS 2.0 + iTunes podcast feeds that Spotify, Apple Podcasts, and any podcast app can subscribe to. Backend: - POST /api/admin/podcast — creates ai_job (kind=podcast), spawns goroutine that generates TTS for missing chapters and writes to MinIO - GET /podcast/{slug}.xml?voice= — public RSS feed with correct pubDate (from chapters_idx.created) and enclosure length (MinIO stat) - GET /podcast/audio/{slug}/{n}/{voice} — public audio proxy, 302 to presigned MinIO URL (no auth required for podcast clients) - GET /api/admin/podcast/{slug} — list podcast jobs for a book - Add AudioObjectSize to AudioStore interface backed by MinIO StatObject - Populate ChapterInfo.Date from chapters_idx.created in ListChapters UI: - New /admin/podcast page: book + voice selector, chapter range, live progress bars, copy-feed-URL button, cancel button, how-to instructions - /api/admin/podcast SvelteKit proxy (injects admin Bearer token) - Podcast link added to admin sidebar Cleanup: - Delete stray playwright screenshot files from repo root - Add .playwright-mcp/ to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + backend/internal/backend/handlers_podcast.go | 524 +++++++++++++++++++ backend/internal/backend/server.go | 8 + backend/internal/bookstore/bookstore.go | 4 + backend/internal/bookstore/bookstore_test.go | 1 + backend/internal/runner/runner_test.go | 3 +- backend/internal/storage/minio.go | 8 + backend/internal/storage/store.go | 15 +- ui/src/routes/admin/+layout.svelte | 5 + ui/src/routes/admin/podcast/+page.server.ts | 30 ++ ui/src/routes/admin/podcast/+page.svelte | 312 +++++++++++ ui/src/routes/api/admin/podcast/+server.ts | 34 ++ 12 files changed, 943 insertions(+), 4 deletions(-) create mode 100644 backend/internal/backend/handlers_podcast.go create mode 100644 ui/src/routes/admin/podcast/+page.server.ts create mode 100644 ui/src/routes/admin/podcast/+page.svelte create mode 100644 ui/src/routes/api/admin/podcast/+server.ts diff --git a/.gitignore b/.gitignore index bd23ced..e3eda84 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db *.swo *~ .opencode/ + +# ── Playwright MCP browser session data ──────────────────────────────────────── +.playwright-mcp/ diff --git a/backend/internal/backend/handlers_podcast.go b/backend/internal/backend/handlers_podcast.go new file mode 100644 index 0000000..51a3746 --- /dev/null +++ b/backend/internal/backend/handlers_podcast.go @@ -0,0 +1,524 @@ +package backend + +// handlers_podcast.go — Podcast RSS feed generation and serving. +// +// Public endpoints (no auth required, suitable for podcast clients): +// +// GET /podcast/{slug} — RSS 2.0 feed (slug may end in .xml) +// ?voice= (optional; defaults to DefaultVoice) +// GET /podcast/audio/{slug}/{n}/{voice} — redirects to a presigned MinIO URL +// +// Admin endpoints (Bearer-token protected): +// +// POST /api/admin/podcast — start a podcast generation job +// GET /api/admin/podcast/{slug} — list podcast jobs for slug + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/libnovel/backend/internal/cfai" + "github.com/libnovel/backend/internal/domain" + "github.com/libnovel/backend/internal/kokoro" + "github.com/libnovel/backend/internal/pockettts" +) + +// ── RSS data structures ──────────────────────────────────────────────────────── + +type podcastFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + ItunesNS string `xml:"xmlns:itunes,attr"` + Channel podcastChannel `xml:"channel"` +} + +type podcastChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language"` + Author string `xml:"itunes:author"` + Image *podcastImage `xml:"image,omitempty"` + ItunesImg *itunesCover `xml:"itunes:image,omitempty"` + Items []podcastItem `xml:"item"` +} + +type podcastImage struct { + URL string `xml:"url"` +} + +type itunesCover struct { + Href string `xml:"href,attr"` +} + +type podcastItem struct { + Title string `xml:"title"` + GUID string `xml:"guid"` + PubDate string `xml:"pubDate,omitempty"` + Enclosure *podcastEnclosure `xml:"enclosure,omitempty"` + Episode int `xml:"itunes:episode,omitempty"` +} + +type podcastEnclosure struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` + Length string `xml:"length,attr"` +} + +// ── Public: RSS feed ─────────────────────────────────────────────────────────── + +// handlePodcastFeed handles GET /podcast/{slug}. +// Slug may optionally end with ".xml" (podcast clients expect it). +// Query param: voice (optional; defaults to DefaultVoice). +func (s *Server) handlePodcastFeed(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimSuffix(r.PathValue("slug"), ".xml") + if slug == "" { + http.NotFound(w, r) + return + } + + voice := r.URL.Query().Get("voice") + if voice == "" { + voice = s.cfg.DefaultVoice + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug) + if err != nil { + http.Error(w, "read metadata: "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.NotFound(w, r) + return + } + + chapters, err := s.deps.BookReader.ListChapters(r.Context(), slug) + if err != nil { + http.Error(w, "list chapters: "+err.Error(), http.StatusInternalServerError) + return + } + + // Determine base URL from request headers (Caddy sets X-Forwarded-Proto/Host). + scheme := "https" + if r.Header.Get("X-Forwarded-Proto") == "http" || (r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "") { + scheme = "http" + } + host := r.Host + if fh := r.Header.Get("X-Forwarded-Host"); fh != "" { + host = fh + } + baseURL := scheme + "://" + host + + var items []podcastItem + for _, ch := range chapters { + audioKey := s.deps.AudioStore.AudioObjectKey(slug, ch.Number, voice) + size := s.deps.AudioStore.AudioObjectSize(r.Context(), audioKey) + if size < 0 { + continue // audio not generated yet + } + title := ch.Title + if title == "" { + title = fmt.Sprintf("Chapter %d", ch.Number) + } + audioURL := fmt.Sprintf("%s/podcast/audio/%s/%d/%s", baseURL, slug, ch.Number, voice) + + // pubDate: use chapter creation date if available, else fallback to now. + pubDate := time.Now().Format(time.RFC1123Z) + if ch.Date != "" { + if t, err := time.Parse(time.RFC3339, ch.Date); err == nil { + pubDate = t.Format(time.RFC1123Z) + } + } + + items = append(items, podcastItem{ + Title: title, + GUID: fmt.Sprintf("%s/podcast/%s/%d/%s", baseURL, slug, ch.Number, voice), + PubDate: pubDate, + Enclosure: &podcastEnclosure{ + URL: audioURL, + Type: "audio/mpeg", + Length: fmt.Sprintf("%d", size), + }, + Episode: ch.Number, + }) + } + + desc := meta.Summary + if desc == "" { + desc = meta.Title + } + + feed := podcastFeed{ + Version: "2.0", + ItunesNS: "http://www.itunes.com/dtds/podcast-1.0.dtd", + Channel: podcastChannel{ + Title: meta.Title, + Link: fmt.Sprintf("%s/books/%s", baseURL, slug), + Description: desc, + Language: "en", + Author: meta.Author, + Items: items, + }, + } + if meta.Cover != "" { + feed.Channel.Image = &podcastImage{URL: meta.Cover} + feed.Channel.ItunesImg = &itunesCover{Href: meta.Cover} + } + + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + _, _ = w.Write([]byte(xml.Header)) + enc := xml.NewEncoder(w) + enc.Indent("", " ") + if err := enc.Encode(feed); err != nil { + s.deps.Log.Error("podcast: encode RSS failed", "slug", slug, "err", err) + } +} + +// handlePodcastAudio handles GET /podcast/audio/{slug}/{n}/{voice}. +// Redirects to a 4-hour presigned MinIO URL. No auth required. +func (s *Server) handlePodcastAudio(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + voice := r.PathValue("voice") + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + http.NotFound(w, r) + return + } + + audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice) + if !s.deps.AudioStore.AudioExists(r.Context(), audioKey) { + http.NotFound(w, r) + return + } + + url, err := s.deps.PresignStore.PresignAudio(r.Context(), audioKey, 4*time.Hour) + if err != nil { + s.deps.Log.Error("podcast: presign audio failed", "slug", slug, "n", n, "err", err) + http.Error(w, "presign failed", http.StatusInternalServerError) + return + } + http.Redirect(w, r, url, http.StatusFound) +} + +// ── Admin: create podcast generation job ────────────────────────────────────── + +type podcastJobRequest struct { + Slug string `json:"slug"` + Voice string `json:"voice"` + FromChapter int `json:"from_chapter"` // 0 = chapter 1 + ToChapter int `json:"to_chapter"` // 0 = all chapters +} + +// handleAdminPodcast handles POST /api/admin/podcast. +// Creates an ai_job of kind="podcast" and spawns a background goroutine that +// generates TTS for any chapters that do not yet have audio. +func (s *Server) handleAdminPodcast(w http.ResponseWriter, r *http.Request) { + if s.deps.AIJobStore == nil { + jsonError(w, http.StatusServiceUnavailable, "ai job store not configured") + return + } + + var req podcastJobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if strings.TrimSpace(req.Slug) == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if strings.TrimSpace(req.Voice) == "" { + req.Voice = s.cfg.DefaultVoice + } + + meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error()) + return + } + if !ok { + jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug)) + return + } + + chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug) + if err != nil { + jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error()) + return + } + if len(chapters) == 0 { + jsonError(w, http.StatusBadRequest, "book has no chapters") + return + } + + from := req.FromChapter + if from <= 0 { + from = 1 + } + to := req.ToChapter + if to <= 0 || to > chapters[len(chapters)-1].Number { + to = chapters[len(chapters)-1].Number + } + if from > to { + jsonError(w, http.StatusBadRequest, "from_chapter must be ≤ to_chapter") + return + } + + paramsJSON, _ := json.Marshal(map[string]any{ + "voice": req.Voice, + "from_chapter": from, + "to_chapter": to, + }) + + jobID, createErr := s.deps.AIJobStore.CreateAIJob(r.Context(), domain.AIJob{ + Kind: "podcast", + Slug: req.Slug, + Status: domain.TaskStatusPending, + Model: req.Voice, + FromItem: from, + ToItem: to, + ItemsTotal: to - from + 1, + Payload: string(paramsJSON), + Started: time.Now(), + }) + if createErr != nil { + jsonError(w, http.StatusInternalServerError, "create job: "+createErr.Error()) + return + } + + jobCtx, jobCancel := context.WithCancel(context.Background()) + registerCancelJob(jobID, jobCancel) + + _ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{ + "status": string(domain.TaskStatusRunning), + }) + + s.deps.Log.Info("admin: podcast job started", + "job_id", jobID, "slug", req.Slug, "voice", req.Voice, + "from", from, "to", to) + + // Capture dependencies for goroutine. + store := s.deps.AIJobStore + bookReader := s.deps.BookReader + audioStore := s.deps.AudioStore + kokoroClient := s.deps.Kokoro + pocketTTSClient := s.deps.PocketTTS + cfaiClient := s.deps.CFAI + logger := s.deps.Log + capturedMeta := meta + capturedChapters := chapters + capturedReq := req + + go func() { + defer deregisterCancelJob(jobID) + defer jobCancel() + + done := 0 + var lastErr string + + for _, ch := range capturedChapters { + if jobCtx.Err() != nil { + _ = store.UpdateAIJob(context.Background(), jobID, map[string]any{ + "status": string(domain.TaskStatusCancelled), + "items_done": done, + "error_message": "cancelled", + "finished": time.Now().Format(time.RFC3339), + }) + return + } + + if ch.Number < from || ch.Number > to { + continue + } + + audioKey := audioStore.AudioObjectKey(capturedMeta.Slug, ch.Number, capturedReq.Voice) + if audioStore.AudioExists(jobCtx, audioKey) { + // Already generated — count it and move on. + done++ + _ = store.UpdateAIJob(context.Background(), jobID, map[string]any{ + "items_done": done, + }) + continue + } + + raw, readErr := bookReader.ReadChapter(jobCtx, capturedMeta.Slug, ch.Number) + if readErr != nil { + lastErr = fmt.Sprintf("ch.%d read: %v", ch.Number, readErr) + logger.Warn("podcast: read chapter failed", + "slug", capturedMeta.Slug, "chapter", ch.Number, "err", readErr) + continue + } + text := stripMarkdown(raw) + if text == "" { + continue + } + + audioData, genErr := podcastGenerateAudio(jobCtx, text, capturedReq.Voice, + kokoroClient, pocketTTSClient, cfaiClient, logger) + if genErr != nil { + lastErr = fmt.Sprintf("ch.%d tts: %v", ch.Number, genErr) + logger.Warn("podcast: tts failed", + "slug", capturedMeta.Slug, "chapter", ch.Number, + "voice", capturedReq.Voice, "err", genErr) + continue + } + + if putErr := audioStore.PutAudio(jobCtx, audioKey, audioData); putErr != nil { + lastErr = fmt.Sprintf("ch.%d put: %v", ch.Number, putErr) + logger.Warn("podcast: put audio failed", + "slug", capturedMeta.Slug, "chapter", ch.Number, "err", putErr) + continue + } + + done++ + _ = store.UpdateAIJob(context.Background(), jobID, map[string]any{ + "items_done": done, + }) + logger.Info("podcast: chapter audio ready", + "slug", capturedMeta.Slug, "chapter", ch.Number, "voice", capturedReq.Voice) + } + + finalStatus := string(domain.TaskStatusDone) + if lastErr != "" && done == 0 { + finalStatus = string(domain.TaskStatusFailed) + } + _ = store.UpdateAIJob(context.Background(), jobID, map[string]any{ + "status": finalStatus, + "items_done": done, + "error_message": lastErr, + "finished": time.Now().Format(time.RFC3339), + }) + logger.Info("podcast: job finished", + "slug", capturedMeta.Slug, "voice", capturedReq.Voice, + "done", done, "status", finalStatus) + }() + + writeJSON(w, http.StatusAccepted, map[string]any{ + "job_id": jobID, + "slug": req.Slug, + "voice": req.Voice, + "from": from, + "to": to, + "items_total": to - from + 1, + }) +} + +// handleAdminPodcastStatus handles GET /api/admin/podcast/{slug}. +// Returns all podcast ai_jobs for slug, newest first. +func (s *Server) handleAdminPodcastStatus(w http.ResponseWriter, r *http.Request) { + if s.deps.AIJobStore == nil { + jsonError(w, http.StatusServiceUnavailable, "ai job store not configured") + return + } + slug := r.PathValue("slug") + + all, err := s.deps.AIJobStore.ListAIJobs(r.Context()) + if err != nil { + jsonError(w, http.StatusInternalServerError, "list jobs: "+err.Error()) + return + } + + var jobs []domain.AIJob + for _, j := range all { + if j.Kind == "podcast" && j.Slug == slug { + jobs = append(jobs, j) + } + } + writeJSON(w, 0, map[string]any{"jobs": jobs}) +} + +// ── TTS helper ───────────────────────────────────────────────────────────────── + +// podcastGenerateAudio generates audio for a full chapter text using the +// appropriate TTS engine for voice. For Kokoro it splits text into ~1 000-char +// sentence-boundary chunks and concatenates the resulting MP3 frames, matching +// the runner's behaviour (see runner.kokoroGenerateChunked). +func podcastGenerateAudio( + ctx context.Context, + text, voice string, + kokoroClient kokoro.Client, + pocketTTSClient pockettts.Client, + cfaiClient cfai.Client, + logger interface{ Warn(string, ...any); Info(string, ...any) }, +) ([]byte, error) { + switch { + case pockettts.IsPocketTTSVoice(voice): + if pocketTTSClient == nil { + return nil, fmt.Errorf("pocket-tts client not configured") + } + return pocketTTSClient.GenerateAudio(ctx, text, voice) + + case cfai.IsCFAIVoice(voice): + if cfaiClient == nil { + return nil, fmt.Errorf("cloudflare AI client not configured") + } + return cfaiClient.GenerateAudio(ctx, text, voice) + + default: // Kokoro + if kokoroClient == nil { + return nil, fmt.Errorf("kokoro client not configured") + } + return podcastKokoroChunked(ctx, kokoroClient, text, voice, logger) + } +} + +// podcastKokoroChunked mirrors runner.kokoroGenerateChunked: splits text into +// ~1 000-character sentence-boundary chunks and concatenates raw MP3 bytes. +func podcastKokoroChunked( + ctx context.Context, + k kokoro.Client, + text, voice string, + logger interface{ Warn(string, ...any); Info(string, ...any) }, +) ([]byte, error) { + const chunkSize = 1000 + chunks := podcastChunkText(text, chunkSize) + logger.Info("podcast: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text)) + + var combined []byte + for i, chunk := range chunks { + data, err := k.GenerateAudio(ctx, chunk, voice) + if err != nil { + return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err) + } + combined = append(combined, data...) + } + return combined, nil +} + +// podcastChunkText mirrors runner.chunkText. +func podcastChunkText(text string, maxChars int) []string { + if len(text) <= maxChars { + return []string{text} + } + delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"} + var chunks []string + remaining := text + for len(remaining) > 0 { + if len(remaining) <= maxChars { + chunks = append(chunks, strings.TrimSpace(remaining)) + break + } + window := remaining[:maxChars] + cutAt := -1 + for _, d := range delimiters { + idx := strings.LastIndex(window, d) + if idx > 0 && idx+len(d) > cutAt { + cutAt = idx + len(d) + } + } + if cutAt <= 0 { + cutAt = maxChars + } + if chunk := strings.TrimSpace(remaining[:cutAt]); chunk != "" { + chunks = append(chunks, chunk) + } + remaining = strings.TrimSpace(remaining[cutAt:]) + } + return chunks +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 022cc28..befc065 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -301,6 +301,14 @@ func (s *Server) ListenAndServe(ctx context.Context) error { admin("GET /api/admin/import", s.handleAdminImportList) admin("GET /api/admin/import/{id}", s.handleAdminImportStatus) + // Podcast RSS feed generation and serving + // Public endpoints — no auth; podcast clients subscribe to these URLs. + mux.HandleFunc("GET /podcast/audio/{slug}/{n}/{voice}", s.handlePodcastAudio) + mux.HandleFunc("GET /podcast/{slug}", s.handlePodcastFeed) + // Admin endpoints — trigger generation job and check status. + admin("POST /api/admin/podcast", s.handleAdminPodcast) + admin("GET /api/admin/podcast/{slug}", s.handleAdminPodcastStatus) + // Notifications mux.HandleFunc("GET /api/notifications", s.handleListNotifications) mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead) diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index 1475470..8c31cad 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -99,6 +99,10 @@ type AudioStore interface { // PutAudio stores raw audio bytes under the given MinIO object key. PutAudio(ctx context.Context, key string, data []byte) error + // AudioObjectSize returns the byte size of the audio object at key, + // or -1 if the object does not exist or size cannot be determined. + AudioObjectSize(ctx context.Context, key string) int64 + // 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" or "audio/wav". diff --git a/backend/internal/bookstore/bookstore_test.go b/backend/internal/bookstore/bookstore_test.go index 57b14b4..9192cbb 100644 --- a/backend/internal/bookstore/bookstore_test.go +++ b/backend/internal/bookstore/bookstore_test.go @@ -57,6 +57,7 @@ func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { ret 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) AudioObjectSize(_ context.Context, _ string) int64 { return -1 } func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error { return nil } diff --git a/backend/internal/runner/runner_test.go b/backend/internal/runner/runner_test.go index ab97f52..8dd3531 100644 --- a/backend/internal/runner/runner_test.go +++ b/backend/internal/runner/runner_test.go @@ -142,7 +142,8 @@ func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string 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) AudioExists(_ context.Context, _ string) bool { return false } +func (s *stubAudioStore) AudioObjectSize(_ context.Context, _ string) int64 { return -1 } func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error { s.putCalled.Add(1) return s.putErr diff --git a/backend/internal/storage/minio.go b/backend/internal/storage/minio.go index e2c9bd9..6134bd3 100644 --- a/backend/internal/storage/minio.go +++ b/backend/internal/storage/minio.go @@ -190,6 +190,14 @@ func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool return err == nil } +func (m *minioClient) objectSize(ctx context.Context, bucket, key string) int64 { + info, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}) + if err != nil { + return -1 + } + return info.Size +} + func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil) if err != nil { diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 79a76ef..f0926ad 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -370,11 +370,16 @@ func (s *Store) ListChapters(ctx context.Context, slug string) ([]domain.Chapter chapters := make([]domain.ChapterInfo, 0, len(items)) for _, raw := range items { var rec struct { - Number int `json:"number"` - Title string `json:"title"` + Number int `json:"number"` + Title string `json:"title"` + Created string `json:"created"` } json.Unmarshal(raw, &rec) - chapters = append(chapters, domain.ChapterInfo{Number: rec.Number, Title: rec.Title}) + chapters = append(chapters, domain.ChapterInfo{ + Number: rec.Number, + Title: rec.Title, + Date: rec.Created, + }) } return chapters, nil } @@ -606,6 +611,10 @@ func (s *Store) AudioExists(ctx context.Context, key string) bool { return s.mc.objectExists(ctx, s.mc.bucketAudio, key) } +func (s *Store) AudioObjectSize(ctx context.Context, key string) int64 { + return s.mc.objectSize(ctx, s.mc.bucketAudio, key) +} + func (s *Store) PutAudio(ctx context.Context, key string, data []byte) error { return s.mc.putObject(ctx, s.mc.bucketAudio, key, "audio/mpeg", data) } diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index f5ef1ed..ff17f93 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -49,6 +49,11 @@ label: () => m.admin_nav_catalogue_tools(), icon: `` }, + { + href: '/admin/podcast', + label: () => 'Podcast', + icon: `` + }, { href: '/admin/changelog', label: () => m.admin_nav_changelog(), diff --git a/ui/src/routes/admin/podcast/+page.server.ts b/ui/src/routes/admin/podcast/+page.server.ts new file mode 100644 index 0000000..52a6af0 --- /dev/null +++ b/ui/src/routes/admin/podcast/+page.server.ts @@ -0,0 +1,30 @@ +import type { PageServerLoad } from './$types'; +import { listBooks, listAIJobs, type Book, type AIJob } from '$lib/server/pocketbase'; +import { backendFetch } from '$lib/server/scraper'; +import { log } from '$lib/server/logger'; + +export type { Book, AIJob }; + +export const load: PageServerLoad = async () => { + const [books, allJobs, voicesRes] = await Promise.all([ + listBooks().catch((e): Book[] => { + log.warn('admin/podcast', 'failed to load books', { err: String(e) }); + return []; + }), + listAIJobs().catch((e): AIJob[] => { + log.warn('admin/podcast', 'failed to load ai jobs', { err: String(e) }); + return []; + }), + backendFetch('/api/voices').catch(() => null) + ]); + + const podcastJobs = allJobs.filter((j) => j.kind === 'podcast'); + + let voices: { id: string; engine: string; lang: string; gender: string }[] = []; + if (voicesRes?.ok) { + const body = await voicesRes.json().catch(() => null); + if (Array.isArray(body?.voices)) voices = body.voices; + } + + return { books, podcastJobs, voices }; +}; diff --git a/ui/src/routes/admin/podcast/+page.svelte b/ui/src/routes/admin/podcast/+page.svelte new file mode 100644 index 0000000..6deb4cc --- /dev/null +++ b/ui/src/routes/admin/podcast/+page.svelte @@ -0,0 +1,312 @@ + + + + Podcast — Admin + + +
+

Podcast Feed

+

+ Generate TTS audio for a book and publish it as a podcast RSS feed that any podcast app can subscribe to. +

+ + +
+

Generate audiobook

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + {#if error} +

{error}

+ {/if} + {#if successMsg} +

{successMsg}

+ {/if} + + +
+ + +

Job history

+ + {#if jobs.length === 0} +

No podcast jobs yet.

+ {:else} +
+ {#each jobs as job (job.id)} +
+
+
+ {job.slug} + voice: {job.model} +
+ {job.status} +
+ + + {#if job.items_total > 0} +
+
+
+

{progress(job)}

+ {/if} + + {#if job.error_message} +

{job.error_message}

+ {/if} + +
+ Started: {fmtDate(job.started ?? '')} + {#if job.finished} + · + Finished: {fmtDate(job.finished)} + {/if} +
+ +
+ + + + + {#if job.status === 'running' || job.status === 'pending'} + + {/if} +
+
+ {/each} +
+ {/if} + + +
+

How to subscribe

+
    +
  1. Click Generate podcast above to create audio for your book.
  2. +
  3. Once running, copy the feed URL with the button above.
  4. +
  5. Open your podcast app (Apple Podcasts, Spotify, Pocket Casts, etc.).
  6. +
  7. Add the feed URL as a custom RSS feed / podcast URL.
  8. +
  9. As more chapters are generated they will appear automatically on the next feed refresh.
  10. +
+

+ Feed URL format: /podcast/<slug>.xml?voice=<voice-id> +

+
+
diff --git a/ui/src/routes/api/admin/podcast/+server.ts b/ui/src/routes/api/admin/podcast/+server.ts new file mode 100644 index 0000000..523b372 --- /dev/null +++ b/ui/src/routes/api/admin/podcast/+server.ts @@ -0,0 +1,34 @@ +/** + * POST /api/admin/podcast + * + * Admin-only proxy to the Go backend's podcast generation endpoint. + * Body: { slug, voice?, from_chapter?, to_chapter? } + * Response 202: { job_id, slug, voice, from, to, items_total } + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const body = await request.text(); + let res: Response; + try { + res = await backendFetch('/api/admin/podcast', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } catch (e) { + log.error('admin/podcast', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +};