feat(podcast): add podcast RSS feed generation and publishing
All checks were successful
Release / Test backend (push) Successful in 6m16s
Release / Test UI (push) Successful in 1m49s
Release / Build and push images (push) Successful in 7m41s
Release / Deploy to prod (push) Successful in 1m45s
Release / Deploy to homelab (push) Successful in 15s
Release / Gitea Release (push) Successful in 43s
All checks were successful
Release / Test backend (push) Successful in 6m16s
Release / Test UI (push) Successful in 1m49s
Release / Build and push images (push) Successful in 7m41s
Release / Deploy to prod (push) Successful in 1m45s
Release / Deploy to homelab (push) Successful in 15s
Release / Gitea Release (push) Successful in 43s
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=<id> — 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 <noreply@anthropic.com>
This commit is contained in:
524
backend/internal/backend/handlers_podcast.go
Normal file
524
backend/internal/backend/handlers_podcast.go
Normal file
@@ -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=<voice-id> (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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user