Compare commits

...

2 Commits

Author SHA1 Message Date
Admin
b4595d3f64 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
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>
2026-04-19 21:44:35 +05:00
Admin
75bfff5a74 fix(ci): resolve docker compose race condition in prod deployment
Some checks failed
Release / Test backend (push) Successful in 6m19s
Release / Test UI (push) Successful in 1m42s
Release / Build and push images (push) Failing after 2m11s
Release / Deploy to prod (push) Has been skipped
Release / Deploy to homelab (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Stop containers explicitly before pulling + restarting to prevent the
'No such container' race condition where Docker Compose tries to start
a new container while simultaneously removing the old one.

Also remove --no-deps flag which caused stale hash-prefixed container
names (e.g. 41cb964156c0_libnovel-ui-1) that trigger the race.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:08:22 +05:00
13 changed files with 945 additions and 5 deletions

View File

@@ -130,7 +130,8 @@ jobs:
'set -euo pipefail
cd /opt/libnovel
doppler run -- docker compose pull backend runner ui caddy pocketbase
doppler run -- docker compose up -d --no-deps --remove-orphans backend runner ui caddy pocketbase'
doppler run -- docker compose stop backend runner ui caddy pocketbase
doppler run -- docker compose up -d --remove-orphans backend runner ui caddy pocketbase'
# ── deploy homelab runner ─────────────────────────────────────────────────────
# Syncs the homelab runner compose file and restarts the runner service.

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ Thumbs.db
*.swo
*~
.opencode/
# ── Playwright MCP browser session data ────────────────────────────────────────
.playwright-mcp/

View 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
}

View File

@@ -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)

View File

@@ -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".

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -49,6 +49,11 @@
label: () => m.admin_nav_catalogue_tools(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />`
},
{
href: '/admin/podcast',
label: () => 'Podcast',
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />`
},
{
href: '/admin/changelog',
label: () => m.admin_nav_changelog(),

View File

@@ -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 };
};

View File

@@ -0,0 +1,312 @@
<script lang="ts">
import { page } from '$app/state';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
// ── Form state ────────────────────────────────────────────────────────────────
let selectedSlug = $state('');
let selectedVoice = $state('');
let fromChapter = $state(0);
let toChapter = $state(0);
let busy = $state(false);
let error = $state('');
let successMsg = $state('');
// ── Job list ──────────────────────────────────────────────────────────────────
let jobs = $state<AIJob[]>(data.podcastJobs);
const hasInFlight = $derived(
jobs.some((j) => j.status === 'pending' || j.status === 'running')
);
// Auto-refresh while jobs are running.
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(async () => {
const res = await fetch('/api/admin/ai-jobs').catch(() => null);
if (res?.ok) {
const body = await res.json().catch(() => null);
if (Array.isArray(body?.jobs)) {
jobs = (body.jobs as AIJob[]).filter((j) => j.kind === 'podcast');
}
}
}, 3000);
return () => clearInterval(id);
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function statusColor(status: string) {
switch (status) {
case 'done': return 'text-green-400';
case 'running': return 'text-(--color-brand) animate-pulse';
case 'pending': return 'text-sky-400 animate-pulse';
case 'failed': return 'text-(--color-danger)';
case 'cancelled': return 'text-(--color-muted)';
default: return 'text-(--color-text)';
}
}
function fmtDate(s: string) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function progress(job: AIJob) {
if (!job.items_total) return '';
const pct = Math.round((job.items_done / job.items_total) * 100);
return `${job.items_done}/${job.items_total} (${pct}%)`;
}
function feedURL(job: AIJob) {
const origin = page.url.origin;
return `${origin}/podcast/${job.slug}.xml?voice=${encodeURIComponent(job.model)}`;
}
async function copyFeedURL(job: AIJob) {
await navigator.clipboard.writeText(feedURL(job)).catch(() => {});
}
// ── Job submission ────────────────────────────────────────────────────────────
async function startJob() {
if (busy || !selectedSlug) return;
error = '';
successMsg = '';
busy = true;
const body: Record<string, unknown> = {
slug: selectedSlug,
voice: selectedVoice || undefined
};
if (fromChapter > 0) body.from_chapter = fromChapter;
if (toChapter > 0) body.to_chapter = toChapter;
try {
const res = await fetch('/api/admin/podcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
error = json.error ?? `HTTP ${res.status}`;
return;
}
successMsg = `Job ${json.job_id} started — generating audio for chapters ${json.from}${json.to}`;
// Optimistically prepend new job placeholder.
jobs = [
{
id: json.job_id,
kind: 'podcast',
slug: selectedSlug,
status: 'running',
model: json.voice,
items_done: 0,
items_total: json.items_total,
from_item: json.from,
to_item: json.to,
started: new Date().toISOString(),
finished: '',
heartbeat_at: '',
error_message: '',
payload: ''
} as AIJob,
...jobs
];
} catch (e) {
error = String(e);
} finally {
busy = false;
}
}
async function cancelJob(id: string) {
await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' }).catch(() => {});
jobs = jobs.map((j) => (j.id === id ? { ...j, status: 'cancelled' as const } : j));
}
</script>
<svelte:head>
<title>Podcast — Admin</title>
</svelte:head>
<div class="max-w-3xl">
<h1 class="text-xl font-bold text-(--color-text) mb-1">Podcast Feed</h1>
<p class="text-sm text-(--color-muted) mb-6">
Generate TTS audio for a book and publish it as a podcast RSS feed that any podcast app can subscribe to.
</p>
<!-- ── Generation form ─────────────────────────────────────────────────── -->
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-5 mb-8 space-y-4">
<h2 class="text-sm font-semibold text-(--color-text)">Generate audiobook</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- Book -->
<div>
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-book">Book</label>
<select
id="podcast-book"
bind:value={selectedSlug}
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
>
<option value="">— select a book —</option>
{#each data.books as book}
<option value={book.slug}>{book.title}</option>
{/each}
</select>
</div>
<!-- Voice -->
<div>
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-voice">Voice</label>
<select
id="podcast-voice"
bind:value={selectedVoice}
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
>
<option value="">— default —</option>
{#each data.voices as v}
<option value={v.id}>{v.id} ({v.engine} · {v.lang} · {v.gender})</option>
{/each}
</select>
</div>
<!-- From chapter -->
<div>
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-from">From chapter</label>
<input
id="podcast-from"
type="number"
min="0"
bind:value={fromChapter}
placeholder="1 (default)"
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
/>
</div>
<!-- To chapter -->
<div>
<label class="block text-xs text-(--color-muted) mb-1" for="podcast-to">To chapter</label>
<input
id="podcast-to"
type="number"
min="0"
bind:value={toChapter}
placeholder="all (default)"
class="w-full bg-(--color-surface) border border-(--color-border) rounded-lg px-3 py-2 text-sm text-(--color-text) focus:outline-none focus:border-(--color-brand)"
/>
</div>
</div>
{#if error}
<p class="text-sm text-(--color-danger)">{error}</p>
{/if}
{#if successMsg}
<p class="text-sm text-green-400">{successMsg}</p>
{/if}
<button
type="button"
disabled={busy || !selectedSlug}
onclick={startJob}
class="px-4 py-2 rounded-lg text-sm font-medium bg-(--color-brand) text-(--color-surface) hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if busy}
<svg class="w-4 h-4 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-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{/if}
Generate podcast
</button>
</div>
<!-- ── Job history ──────────────────────────────────────────────────────── -->
<h2 class="text-sm font-semibold text-(--color-text) mb-3">Job history</h2>
{#if jobs.length === 0}
<p class="text-sm text-(--color-muted)">No podcast jobs yet.</p>
{:else}
<div class="space-y-3">
{#each jobs as job (job.id)}
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-4 flex flex-col gap-2">
<div class="flex items-start justify-between gap-2 flex-wrap">
<div>
<span class="text-sm font-medium text-(--color-text)">{job.slug}</span>
<span class="text-xs text-(--color-muted) ml-2">voice: {job.model}</span>
</div>
<span class="text-xs font-semibold {statusColor(job.status)}">{job.status}</span>
</div>
<!-- Progress bar -->
{#if job.items_total > 0}
<div class="w-full bg-(--color-surface) rounded-full h-1.5">
<div
class="bg-(--color-brand) h-1.5 rounded-full transition-all"
style="width: {Math.round((job.items_done / job.items_total) * 100)}%"
></div>
</div>
<p class="text-xs text-(--color-muted)">{progress(job)}</p>
{/if}
{#if job.error_message}
<p class="text-xs text-(--color-danger)">{job.error_message}</p>
{/if}
<div class="flex items-center gap-2 flex-wrap text-xs text-(--color-muted)">
<span>Started: {fmtDate(job.started ?? '')}</span>
{#if job.finished}
<span>·</span>
<span>Finished: {fmtDate(job.finished)}</span>
{/if}
</div>
<div class="flex gap-2 flex-wrap mt-1">
<!-- Feed URL (only useful once job is done/in-progress) -->
<button
type="button"
onclick={() => copyFeedURL(job)}
class="text-xs px-2.5 py-1 rounded-lg bg-(--color-surface) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1.5"
>
<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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy feed URL
</button>
<!-- Cancel button for running/pending jobs -->
{#if job.status === 'running' || job.status === 'pending'}
<button
type="button"
onclick={() => cancelJob(job.id)}
class="text-xs px-2.5 py-1 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors"
>
Cancel
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- ── How to subscribe ─────────────────────────────────────────────────── -->
<div class="mt-8 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-5">
<h2 class="text-sm font-semibold text-(--color-text) mb-2">How to subscribe</h2>
<ol class="text-sm text-(--color-muted) list-decimal list-inside space-y-1">
<li>Click <strong class="text-(--color-text)">Generate podcast</strong> above to create audio for your book.</li>
<li>Once running, copy the feed URL with the button above.</li>
<li>Open your podcast app (Apple Podcasts, Spotify, Pocket Casts, etc.).</li>
<li>Add the feed URL as a custom RSS feed / podcast URL.</li>
<li>As more chapters are generated they will appear automatically on the next feed refresh.</li>
</ol>
<p class="text-xs text-(--color-muted) mt-3">
Feed URL format: <code class="bg-(--color-surface) px-1 py-0.5 rounded text-xs">/podcast/&lt;slug&gt;.xml?voice=&lt;voice-id&gt;</code>
</p>
</div>
</div>

View File

@@ -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 });
};