diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8d47b42..1dbe270af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ the archive; if it broke production, it belongs in `docs/incidents/`. See - **Ops** — deploys no longer run a full SSG rebuild (~25 min of CPU, held the only runner): it runs nightly after the backup; `rebuild_ssg` input on a manual deploy for releases that change SEO rendering; health check alarms at 36h stale (was 72h) — infra - **Security** — hardening from the architecture review: stored files are served with a sandbox CSP + nosniff (nginx and the API), `/internal/*` is refused at nginx and its network check is one tested helper, two copyrighted test PDFs removed (image-only test now uses a generated PDF) — backend, infra - **Security** — connect keys get the same account-route scope as OAuth tokens; per-IP rate limits group IPv6 by /64; nginx rate-limit zones key on the client address — api, infra +- **Cleanup** — podcast feature deleted: worker, script builder, ffmpeg audio assembly, `/books/{slug}/podcast` + `/admin/podcasts` endpoints, `podcast.script` route + eval, web book-page player, admin Generate button; `podcast_generation_jobs` dropped (`DropPodcast`), ffmpeg removed from the Worker image; stored mp3s left on disk — backend, admin, web - **Discover** — the "Learn a language by reading real books" card no longer fills a small screen: it scrolls with the page under the search box, and a × hides it for good (`onboarding.startReadingCard.dismissed`) — closed-test report, Unihertz Titan 2 — mobile - **Docs** — docs checked against the code before the architecture review: STATUS, CLAUDE.md, architecture + system docs, ADR status lines, feature/ops/dev docs; `[Unreleased]` cut into deploy-date headings; stale plans marked historical — docs - **Ops** — health check hits `/api/health` and asserts `"healthy"` (`/health` was the SPA's 200); `pg_dump | gzip` under pipefail + dump-trailer check (backup, pre-deploy); daily disk alarm at 85% (`/`, docker root); deploy polls the API instead of `sleep 30` and fails on unhealthy services; host Claude CLI pollers run from a temp dir with `--tools ""`; deploy restarts `quality-poller` — infra diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts index 014f86534..4075a7903 100644 --- a/apps/admin/src/api/client.ts +++ b/apps/admin/src/api/client.ts @@ -1,8 +1,5 @@ export const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8080' -/// Origin that serves /storage (strip a trailing /api so podcast mp3 urls resolve). -export const mediaBase = API_BASE.replace(/\/api\/?$/, '') - export interface IngestionJob { id: string editionId: string @@ -443,12 +440,6 @@ export interface EvalStatus { startedAt: string | null lastError: string | null } -export interface PodcastStatusDto { - jobId: string - status: 'Queued' | 'Running' | 'Succeeded' | 'Failed' - audioUrl: string | null - durationSeconds: number | null -} export interface AiQualitySummary { from: string to: string @@ -1402,23 +1393,6 @@ export const adminApi = { }) }, - // Podcasts - generatePodcast: async (editionId: string, lang?: string, force?: boolean): Promise => { - return fetchJson('/admin/podcasts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ editionId, lang, force }), - }) - }, - - getPodcastStatus: async (editionId: string): Promise => { - try { - return await fetchJson(`/admin/podcasts/${editionId}`) - } catch { - return null // 404 = not generated yet - } - }, - // ── SEO Backfill ── getSeoCoverage: async (): Promise => { return fetchJson('/admin/seo/coverage') diff --git a/apps/admin/src/pages/EditEditionPage.tsx b/apps/admin/src/pages/EditEditionPage.tsx index 90e310825..8b34a5777 100644 --- a/apps/admin/src/pages/EditEditionPage.tsx +++ b/apps/admin/src/pages/EditEditionPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, FormEvent } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' -import { adminApi, EditionDetail, PodcastStatusDto, mediaBase } from '../api/client' +import { adminApi, EditionDetail } from '../api/client' import { AuthorAutocomplete } from '../components/AuthorAutocomplete' import { AuthorList, AuthorItem } from '../components/AuthorList' import { CreateAuthorModal } from '../components/CreateAuthorModal' @@ -24,8 +24,6 @@ export function EditEditionPage() { const [uploadingCover, setUploadingCover] = useState(false) const [deletingCover, setDeletingCover] = useState(false) const [error, setError] = useState(null) - const [podcast, setPodcast] = useState(null) - const [podcastBusy, setPodcastBusy] = useState(false) const [title, setTitle] = useState('') const [authors, setAuthors] = useState([]) @@ -126,35 +124,6 @@ export function EditEditionPage() { setNewAuthorName('') } - // Podcast: load latest status on mount, poll while generating. - useEffect(() => { - if (!id) return - let active = true - adminApi.getPodcastStatus(id).then((p) => { if (active) setPodcast(p) }) - return () => { active = false } - }, [id]) - - useEffect(() => { - if (!id || (podcast?.status !== 'Queued' && podcast?.status !== 'Running')) return - const timer = setInterval(async () => { - const p = await adminApi.getPodcastStatus(id) - setPodcast(p) - }, 3000) - return () => clearInterval(timer) - }, [id, podcast?.status]) - - const handleGeneratePodcast = async (force = false) => { - if (!id) return - setPodcastBusy(true) - try { - setPodcast(await adminApi.generatePodcast(id, undefined, force)) - } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to start podcast') - } finally { - setPodcastBusy(false) - } - } - const handlePublish = async () => { if (!id) return try { @@ -476,38 +445,6 @@ export function EditEditionPage() { -
-

Podcast

-
- - {(podcast?.status === 'Succeeded' || podcast?.status === 'Failed') && ( - - )} -
- {podcast && ( -

- Status: {podcast.status} - {podcast.durationSeconds ? ` · ${podcast.durationSeconds}s` : ''} -

- )} - {podcast?.status === 'Succeeded' && podcast.audioUrl && ( -
- {showCreateModal && ( (`${langPrefix}/books/${slug}`) }, - getPodcast: (slug: string) => { - return fetchJson(`${langPrefix}/books/${slug}/podcast`) - }, - getChapter: (bookSlug: string, chapterSlug: string) => { return fetchJson( `${langPrefix}/books/${bookSlug}/chapters/${chapterSlug}` diff --git a/apps/web/src/components/BookPodcastPlayer.tsx b/apps/web/src/components/BookPodcastPlayer.tsx deleted file mode 100644 index 3108cb819..000000000 --- a/apps/web/src/components/BookPodcastPlayer.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' - -interface BookPodcastPlayerProps { - src: string - title: string - coverUrl?: string | null - /** Server-reported length; used as a fallback until the media metadata loads. */ - durationSeconds?: number | null -} - -const SPEEDS = [1, 1.25, 1.5, 1.75, 2] as const - -function fmt(sec: number): string { - if (!Number.isFinite(sec) || sec < 0) return '0:00' - const m = Math.floor(sec / 60) - const s = Math.floor(sec % 60) - return `${m}:${s.toString().padStart(2, '0')}` -} - -/** - * Custom, on-brand audio player for a book's AI-narrated intro — replaces the raw - * browser