Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).
Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.
/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.
Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).
API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.
Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
// Podcast template view types (plan task 1.9): the SSR shape shared by
|
|
// the /podcasts page and the feed-detail page. The server formats
|
|
// everything display-ready (dates in the requesting user's timezone —
|
|
// D17; durations human) so templates stay logic-free.
|
|
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookhoard/internal/utils"
|
|
)
|
|
|
|
// PodcastFeedCard is one grid card on /podcasts (or the hero on the
|
|
// detail page).
|
|
type PodcastFeedCard struct {
|
|
ID string
|
|
LibraryID string
|
|
Title string
|
|
DisplayName string // subscription-owned; renders instead of Title when set
|
|
Description string
|
|
Host string
|
|
Publisher string
|
|
Language string
|
|
Tags []string
|
|
Explicit bool
|
|
ArtworkPath string
|
|
FeedType string // serial | episodic
|
|
Medium string
|
|
IsComplete bool
|
|
FeedURL string
|
|
LastFetched string // display-formatted
|
|
SubscribedAt string
|
|
|
|
EpisodeTotal int
|
|
DownloadedN int
|
|
UndownloadedN int
|
|
NewEpisodes bool
|
|
|
|
// Subscription-owned policy (detail page settings block).
|
|
AutoDownload string
|
|
AutoDownloadCount int
|
|
Subscribed bool
|
|
}
|
|
|
|
// DisplayTitle renders the subscription display name when set (D9).
|
|
func (c PodcastFeedCard) DisplayTitle() string {
|
|
if c.DisplayName != "" {
|
|
return c.DisplayName
|
|
}
|
|
return c.Title
|
|
}
|
|
|
|
// TypeLabel renders the feed's shape for the card badge.
|
|
func (c PodcastFeedCard) TypeLabel() string {
|
|
if c.FeedType == "serial" {
|
|
return "Serial"
|
|
}
|
|
if c.Medium != "" {
|
|
return strings.ToUpper(c.Medium[:1]) + c.Medium[1:]
|
|
}
|
|
return "Podcast"
|
|
}
|
|
|
|
// PodcastEpisodeRow is one episode-list line with the download
|
|
// lifecycle inline (D8/D19: pending greyed + download button,
|
|
// queued/downloading spinner, failed/rejected red + reason + retry).
|
|
type PodcastEpisodeRow struct {
|
|
ID string
|
|
FeedID string
|
|
Title string
|
|
Description string
|
|
Date string // display-formatted (user tz)
|
|
Position int
|
|
Duration string // human ("58:02" / "1:02:10")
|
|
Size string // human MB/GB when known
|
|
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
|
CoverImage string
|
|
ProgressPct float64
|
|
Downloaded bool
|
|
DownloadState string // pending|queued|downloading|downloaded|failed
|
|
DownloadError string
|
|
}
|
|
|
|
// StateBadge renders the canonical state label.
|
|
func (r PodcastEpisodeRow) StateBadge() string {
|
|
switch r.DownloadState {
|
|
case "pending":
|
|
return "Not downloaded"
|
|
case "queued":
|
|
return "Queued"
|
|
case "downloading":
|
|
return "Downloading…"
|
|
case "failed":
|
|
return "Failed"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// HasActiveDownloads: any episode queued/downloading — the partial
|
|
// polls only while this is true (server-driven polling cadence).
|
|
func HasActiveDownloads(rows []PodcastEpisodeRow) bool {
|
|
for _, r := range rows {
|
|
if r.DownloadState == "queued" || r.DownloadState == "downloading" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FormatEpisodeDuration renders seconds as H:MM:SS or M:SS.
|
|
func FormatEpisodeDuration(seconds int) string {
|
|
if seconds <= 0 {
|
|
return ""
|
|
}
|
|
h := seconds / 3600
|
|
m := (seconds % 3600) / 60
|
|
s := seconds % 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
|
}
|
|
return fmt.Sprintf("%d:%02d", m, s)
|
|
}
|
|
|
|
// FormatEpisodeSize renders bytes human (MB/GB).
|
|
func FormatEpisodeSize(bytes int64) string {
|
|
switch {
|
|
case bytes <= 0:
|
|
return ""
|
|
case bytes >= 1<<30:
|
|
return fmt.Sprintf("%.1f GB", float64(bytes)/(1<<30))
|
|
default:
|
|
return fmt.Sprintf("%.1f MB", float64(bytes)/(1<<20))
|
|
}
|
|
}
|
|
|
|
// PodcastsPageData carries everything the /podcasts page renders.
|
|
type PodcastsPageData struct {
|
|
Feeds []PodcastFeedCard
|
|
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
|
|
Facets map[string][]string
|
|
Query string
|
|
ErrorMsg string
|
|
HasLibrary bool
|
|
}
|
|
|
|
// PodcastDetailData carries the feed-detail page render.
|
|
type PodcastDetailData struct {
|
|
Feed PodcastFeedCard
|
|
Episodes []PodcastEpisodeRow
|
|
GapCount int
|
|
Order string // native | oldest | newest (the caller's override)
|
|
IsAdmin bool
|
|
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
|
}
|
|
|
|
// podcastInt renders an int for templates.
|
|
func podcastInt(n int) string {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
// timeStringPtr helper: display-format with empty fallback.
|
|
func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
|
|
if !valid || t.IsZero() {
|
|
return ""
|
|
}
|
|
return utils.FormatTimeInTimezone(t, tz)
|
|
}
|