Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 0 additions & 26 deletions apps/admin/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1402,23 +1393,6 @@ export const adminApi = {
})
},

// Podcasts
generatePodcast: async (editionId: string, lang?: string, force?: boolean): Promise<PodcastStatusDto> => {
return fetchJson<PodcastStatusDto>('/admin/podcasts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editionId, lang, force }),
})
},

getPodcastStatus: async (editionId: string): Promise<PodcastStatusDto | null> => {
try {
return await fetchJson<PodcastStatusDto>(`/admin/podcasts/${editionId}`)
} catch {
return null // 404 = not generated yet
}
},

// ── SEO Backfill ──
getSeoCoverage: async (): Promise<SeoCoverageStat[]> => {
return fetchJson<SeoCoverageStat[]>('/admin/seo/coverage')
Expand Down
65 changes: 1 addition & 64 deletions apps/admin/src/pages/EditEditionPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -24,8 +24,6 @@ export function EditEditionPage() {
const [uploadingCover, setUploadingCover] = useState(false)
const [deletingCover, setDeletingCover] = useState(false)
const [error, setError] = useState<string | null>(null)
const [podcast, setPodcast] = useState<PodcastStatusDto | null>(null)
const [podcastBusy, setPodcastBusy] = useState(false)

const [title, setTitle] = useState('')
const [authors, setAuthors] = useState<AuthorItem[]>([])
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -476,38 +445,6 @@ export function EditEditionPage() {
</div>
</div>

<div className="edition-actions">
<h2>Podcast</h2>
<div className="action-buttons">
<button
onClick={() => handleGeneratePodcast(false)}
disabled={podcastBusy || podcast?.status === 'Queued' || podcast?.status === 'Running'}
className="btn btn--secondary"
>
{podcast?.status === 'Queued' || podcast?.status === 'Running' ? 'Generating…' : 'Generate podcast'}
</button>
{(podcast?.status === 'Succeeded' || podcast?.status === 'Failed') && (
<button
onClick={() => handleGeneratePodcast(true)}
disabled={podcastBusy}
className="btn btn--secondary"
title="Re-run generation with the current prompt"
>
Regenerate
</button>
)}
</div>
{podcast && (
<p style={{ marginTop: 8 }}>
Status: <strong>{podcast.status}</strong>
{podcast.durationSeconds ? ` · ${podcast.durationSeconds}s` : ''}
</p>
)}
{podcast?.status === 'Succeeded' && podcast.audioUrl && (
<audio controls src={`${mediaBase}${podcast.audioUrl}`} style={{ width: '100%', marginTop: 8 }} />
)}
</div>

{showCreateModal && (
<CreateAuthorModal
siteId={edition.siteId}
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ import './styles/chapter-review.css'
import './styles/auth.css'
import './styles/profile.css'
import './styles/dropzone.css'
import './styles/podcast-player.css'

function AuthSuccessToast() {
const { authSuccessToast, dismissAuthSuccessToast } = useAuth()
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,6 @@ export function createApi(language: string) {
return fetchJson<import('../types/api').BookDetail>(`${langPrefix}/books/${slug}`)
},

getPodcast: (slug: string) => {
return fetchJson<import('../types/api').PodcastStatusDto>(`${langPrefix}/books/${slug}/podcast`)
},

getChapter: (bookSlug: string, chapterSlug: string) => {
return fetchJson<import('../types/api').Chapter>(
`${langPrefix}/books/${bookSlug}/chapters/${chapterSlug}`
Expand Down
167 changes: 0 additions & 167 deletions apps/web/src/components/BookPodcastPlayer.tsx

This file was deleted.

22 changes: 1 addition & 21 deletions apps/web/src/pages/BookDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { useDownload } from '../context/DownloadContext'
import { useLibrary } from '../hooks/useLibrary'
import { useSite } from '../context/SiteContext'
import { LocalizedLink } from '../components/LocalizedLink'
import { BookPodcastPlayer } from '../components/BookPodcastPlayer'
import { SeoHead } from '../components/SeoHead'
import { JsonLd } from '../components/JsonLd'
import { Breadcrumbs } from '../components/Breadcrumbs'
Expand All @@ -32,7 +31,7 @@ import { useAuth } from '../context/AuthContext'
import { BookDetailHero } from '../components/BookDetailHero'
import { AddToCollectionButton } from '../components/library/AddToCollectionButton'
import { isNotFoundError } from '../lib/errorUtils'
import type { BookDetail, PodcastStatusDto } from '../types/api'
import type { BookDetail } from '../types/api'

// Strip HTML tags from description text
function stripHtml(html: string): string {
Expand Down Expand Up @@ -81,7 +80,6 @@ export function BookDetailPage() {
const [error, setError] = useState<Error | null>(null)
const [isOffline, setIsOffline] = useState(false)
const [showAllChapters, setShowAllChapters] = useState(false)
const [podcast, setPodcast] = useState<PodcastStatusDto | null>(null)
// Which chapters have a review — signed-in only; this page is also prerendered for crawlers.
const { reviews, insights, remove: removeInsight } = useBookReviews(isAuthenticated && book?.id ? { editionId: book.id } : null)

Expand All @@ -96,15 +94,6 @@ export function BookDetailPage() {
return () => { cancelled = true }
}, [bookSlug, api])

useEffect(() => {
if (!bookSlug) return
let cancelled = false
api.getPodcast(bookSlug)
.then((p) => { if (!cancelled) setPodcast(p) })
.catch(() => { if (!cancelled) setPodcast(null) }) // 404 = no podcast yet
return () => { cancelled = true }
}, [bookSlug, api])

// Check offline status when book loads
useEffect(() => {
if (!book?.id) return
Expand Down Expand Up @@ -316,15 +305,6 @@ export function BookDetailPage() {
current={currentReviewChapter(book.chapters, continueSlug, reviews)}
/>

{podcast?.status === 'Succeeded' && podcast.audioUrl && (
<BookPodcastPlayer
src={podcast.audioUrl}
title={book.title}
coverUrl={book.coverPath ? getStorageUrl(book.coverPath) : null}
durationSeconds={podcast.durationSeconds}
/>
)}

{book.id && isDownloading(book.id) && (
<span className="book-detail__download-status book-detail__download-status--downloading">
{t('bookDetail.downloading').replace('{progress}', String(getProgress(book.id)))}
Expand Down
Loading
Loading