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:
@@ -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(),
|
||||
|
||||
30
ui/src/routes/admin/podcast/+page.server.ts
Normal file
30
ui/src/routes/admin/podcast/+page.server.ts
Normal 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 };
|
||||
};
|
||||
312
ui/src/routes/admin/podcast/+page.svelte
Normal file
312
ui/src/routes/admin/podcast/+page.svelte
Normal 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/<slug>.xml?voice=<voice-id></code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
34
ui/src/routes/api/admin/podcast/+server.ts
Normal file
34
ui/src/routes/api/admin/podcast/+server.ts
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user