WHY: downloads needed manual refreshes to see progress; the 204 + HX-Trigger event indirection missed its listener, and the worker buffered whole enclosures so no progress existed to show. - Worker streams enclosures to disk through a counting loop (64KiB chunks, scheme/redirect/size guards preserved) with an in-memory per-episode progress snapshot (received/total; total from Content-Length, seeded by the feed's declared length). Deliberately transient — the DB row state remains the durable truth. - Episode-list renderer seam: the router injects a closure into PodcastHandler (handlers can't import templates — MediaDetail cycle) so htmx download/retry/Download All/refresh/order/policy actions answer with the REFRESHED partial directly (200, hx-target= #episode-list) — no events to miss. Non-HX callers keep JSON; tests without a renderer keep 204+trigger. - Polling tightens to 2s while downloads are active; downloading rows render a percentage bar + MB counter (quote-free inline style per templ gotcha #8) or an indeterminate bar when the server sends no length. - Issue #3: Download All (button AND the add-feed checkbox) flips the CALLER's subscription policy to 'all' — per-user, never global (D6: files shared, policies not) — and the auto-download select out-of-band-swaps so the dropdown shows what the user is getting. They can turn it down afterwards; the flip is honest communication, not coercion (user-approved 2026-10-29). PINs: Download All with HX answers 200 + episode-list + oob select and flips newest → all; add-feed download_all sets policy all.
435 lines
17 KiB
Templ
435 lines
17 KiB
Templ
package templates
|
|
|
|
// Feed detail page (plan task 1.9, D5/D7/D8/D19): hero (artwork,
|
|
// metadata, admin cluster), action row (Download All, Refresh, order
|
|
// override, subscription policy, admin delete), and the episode list
|
|
// with the download lifecycle inline. The episode list is a self-
|
|
// refreshing htmx partial: it re-fetches on the podcast:changed event
|
|
// AND polls every 4s ONLY while downloads are active (server decides —
|
|
// the polling attributes render only then).
|
|
|
|
import "fmt"
|
|
|
|
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ data.Feed.DisplayTitle() } - Bookhoard</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/podcasts")
|
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
|
|
@Icon("arrow-left", "h-4 w-4")
|
|
<span>Podcasts</span>
|
|
</a>
|
|
|
|
<!-- Hero -->
|
|
<div class="flex flex-col sm:flex-row gap-6 mb-8">
|
|
<div class="h-44 w-44 rounded-xl shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
|
|
if data.Feed.ArtworkPath != "" {
|
|
<img
|
|
src={ "/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath }
|
|
alt={ "Cover for " + data.Feed.DisplayTitle() }
|
|
class="h-full w-full object-cover"
|
|
data-image-fallback
|
|
/>
|
|
} else {
|
|
@Icon("podcast", "h-12 w-12")
|
|
}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">{ data.Feed.TypeLabel() }</span>
|
|
if data.Feed.IsComplete {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
|
|
}
|
|
if data.Feed.Language != "" {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ data.Feed.Language }</span>
|
|
}
|
|
</div>
|
|
<h1 class="text-2xl font-bold tracking-tight mb-1" style="color: var(--text-primary)">{ data.Feed.DisplayTitle() }</h1>
|
|
if data.Feed.Host != "" || data.Feed.Publisher != "" {
|
|
<p class="text-sm mb-2" style="color: var(--text-secondary)">
|
|
if data.Feed.Host != "" {
|
|
{ "Host: " + data.Feed.Host }
|
|
}
|
|
if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host {
|
|
{ " · Publisher: " + data.Feed.Publisher }
|
|
}
|
|
</p>
|
|
}
|
|
if data.Feed.Description != "" {
|
|
<p class="text-sm line-clamp-3 mb-2" style="color: var(--text-secondary)">{ data.Feed.Description }</p>
|
|
}
|
|
if len(data.Feed.Tags) > 0 {
|
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
|
for _, t := range data.Feed.Tags {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ t }</span>
|
|
}
|
|
</div>
|
|
}
|
|
<p class="text-xs" style="color: var(--text-secondary)">
|
|
{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" }
|
|
if data.Feed.LastFetched != "" {
|
|
{ " · last checked " + data.Feed.LastFetched }
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-6">
|
|
<button
|
|
class="btn btn-primary"
|
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
title="Queue every episode that is not downloaded yet"
|
|
>
|
|
@Icon("save", "h-4 w-4")
|
|
<span>Download All</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
title="Check the feed for new episodes now"
|
|
>
|
|
@Icon("refresh", "h-4 w-4")
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<!-- Order override (D5): per-user; refreshes the episode list -->
|
|
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
|
|
<select
|
|
id="podcast-order-select"
|
|
name="order"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
|
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
|
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
|
|
</select>
|
|
|
|
<!-- Serial suggestion (D5): one-click, never silent -->
|
|
if data.IsSerial && data.Order == "native" {
|
|
<button
|
|
class="btn btn-secondary text-sm"
|
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
|
hx-vals='{"order": "oldest"}'
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
title="Serial feed: publishers list newest first — listen oldest-first"
|
|
>
|
|
<span>Serial? Listen oldest-first</span>
|
|
</button>
|
|
}
|
|
|
|
<!-- Auto-download policy (D6) -->
|
|
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
|
@PodcastAutoDownloadSelect(data.Feed)
|
|
if data.Feed.AutoDownload == "newest" {
|
|
<input
|
|
type="number"
|
|
id="podcast-autodl-count"
|
|
name="auto_download_count"
|
|
min="1"
|
|
max="100"
|
|
value={ podcastInt(data.Feed.AutoDownloadCount) }
|
|
class="input w-20 py-1.5"
|
|
aria-label="Newest N episodes"
|
|
/>
|
|
}
|
|
|
|
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
|
an admin gets the three-choice modal (unsubscribe /
|
|
remove feed / cancel); everyone else gets a plain
|
|
confirm — file retention is admin knowledge only. -->
|
|
if data.IsAdmin && data.SubscriberCount <= 1 {
|
|
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
|
|
<span>Unsubscribe</span>
|
|
</button>
|
|
} else if data.IsAdmin {
|
|
<button
|
|
class="btn btn-secondary ml-auto"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
|
|
hx-swap="none"
|
|
>
|
|
<span>Unsubscribe</span>
|
|
</button>
|
|
} else {
|
|
<button
|
|
class="btn btn-secondary ml-auto"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-confirm="Unsubscribe from this feed?"
|
|
hx-swap="none"
|
|
>
|
|
<span>Unsubscribe</span>
|
|
</button>
|
|
}
|
|
|
|
if data.IsAdmin {
|
|
<button
|
|
class="btn btn-secondary"
|
|
style="color: var(--status-danger);"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
|
hx-confirm="ADMIN: remove this feed for everyone? Episode rows AND downloaded files are deleted."
|
|
hx-swap="none"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove Feed</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
<!-- Gap banner (D8): middle episodes missing -->
|
|
if data.GapCount > 0 {
|
|
<div class="mb-4 card p-4 flex items-center gap-3" style="border-color: var(--status-warning, #d97706);">
|
|
@Icon("circle", "h-5 w-5")
|
|
<div>
|
|
<p class="font-semibold text-sm" style="color: var(--text-primary)">{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }</p>
|
|
<p class="text-xs" style="color: var(--text-secondary)">Playback skips them and keeps going — use Download All to fill the gaps.</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Episode list (self-refreshing htmx partial) -->
|
|
@PodcastEpisodeList(data)
|
|
</main>
|
|
@PodcastUnsubscribeModal(data)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// PodcastEpisodeList is the refresh unit: re-fetched on the
|
|
// podcast:changed event (download/refresh/order endpoints fire it) and
|
|
// polled every 4s only while the server says downloads are active.
|
|
templ PodcastEpisodeList(data PodcastDetailData) {
|
|
<div
|
|
id="episode-list"
|
|
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
|
hx-trigger="podcast:changed from:body"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
|
if len(data.Episodes) == 0 {
|
|
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">
|
|
No episodes yet — hit Refresh to check the feed.
|
|
</div>
|
|
} else {
|
|
<div class="flex flex-col gap-2">
|
|
for _, ep := range data.Episodes {
|
|
@PodcastEpisodeRowView(ep)
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// PodcastEpisodeListPolling renders the poll-active variant of the list
|
|
// container (same content; the hx-trigger adds the 4s poll + load). The
|
|
// SSR episode route picks one of the two by download activity.
|
|
templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
|
<div
|
|
id="episode-list"
|
|
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
|
hx-trigger="podcast:changed from:body, every 2s"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
|
<div class="flex flex-col gap-2">
|
|
for _, ep := range data.Episodes {
|
|
@PodcastEpisodeRowView(ep)
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
|
<div class="card p-3 flex items-center gap-3">
|
|
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
|
|
{ podcastInt(ep.Position) }
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<a href={ ep.PlayURL } class="font-medium text-sm hover:underline line-clamp-1" style="color: var(--text-primary)">{ ep.Title }</a>
|
|
if ep.DownloadState == "failed" {
|
|
<span class="badge shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); color: var(--status-danger);">{ ep.StateBadge() }</span>
|
|
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
|
<span class="badge shrink-0 animate-pulse" style="background-color: var(--accent-muted); color: var(--accent);">{ ep.StateBadge() }</span>
|
|
} else if ep.DownloadState == "pending" {
|
|
<span class="badge shrink-0" style="background-color: var(--surface-secondary); color: var(--text-secondary);">{ ep.StateBadge() }</span>
|
|
}
|
|
</div>
|
|
if ep.DownloadError != "" {
|
|
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
|
}
|
|
if ep.DownloadState == "downloading" && ep.ProgressKnown {
|
|
<div class="mt-1.5">
|
|
if ep.ProgressTotal > 0 {
|
|
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
|
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
|
|
</div>
|
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
|
|
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
|
|
</p>
|
|
} else {
|
|
<div class="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);">
|
|
<div class="h-full w-1/3 rounded-full" style="background-color: var(--accent);"></div>
|
|
</div>
|
|
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
|
|
}
|
|
</div>
|
|
}
|
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
|
{ ep.Date }
|
|
if ep.Duration != "" {
|
|
{ " · " + ep.Duration }
|
|
}
|
|
if ep.Size != "" {
|
|
{ " · " + ep.Size }
|
|
}
|
|
</p>
|
|
</div>
|
|
if ep.Downloaded {
|
|
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)">
|
|
@Icon("book-open", "h-4 w-4")
|
|
<span>Play</span>
|
|
</a>
|
|
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
|
<span class="btn btn-secondary shrink-0 pointer-events-none opacity-60" aria-busy="true">
|
|
@Icon("refresh", "h-4 w-4")
|
|
</span>
|
|
} else {
|
|
<!-- pending AND failed: (re)download — same endpoint, idempotent -->
|
|
<button
|
|
class={ "btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed") }
|
|
hx-post={ "/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download" }
|
|
hx-swap="none"
|
|
title={ downloadButtonTitle(ep.DownloadState) }
|
|
>
|
|
@Icon("save", "h-4 w-4")
|
|
<span>{ downloadButtonLabel(ep.DownloadState) }</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// downloadButtonLabel: pending → Download; failed → Retry (the reason
|
|
// text above it explains what went wrong — D8/D19 inline failure UX).
|
|
func downloadButtonLabel(state string) string {
|
|
if state == "failed" {
|
|
return "Retry"
|
|
}
|
|
return "Download"
|
|
}
|
|
|
|
// progressWidth renders the bar width as a quote-free inline style
|
|
// fragment (templ gotcha #8: style attribute values must not carry
|
|
// quote characters).
|
|
func progressWidth(pct float64) string {
|
|
if pct < 1 {
|
|
pct = 1
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
return fmt.Sprintf("width: %.1f%%;", pct)
|
|
}
|
|
|
|
func downloadButtonTitle(state string) string {
|
|
if state == "failed" {
|
|
return "Retry the download"
|
|
}
|
|
return "Download this episode"
|
|
}
|
|
|
|
|
|
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
|
|
// so the Download All response can out-of-band-swap it after flipping
|
|
// the caller's policy — the dropdown must reflect what the user gets.
|
|
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
|
<select
|
|
id="podcast-autodl-select"
|
|
name="auto_download"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
|
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
|
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
|
</select>
|
|
}
|
|
|
|
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
|
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
|
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
|
<select
|
|
id="podcast-autodl-select"
|
|
name="auto_download"
|
|
hx-swap-oob="true"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
|
hx-target="#episode-list"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
|
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
|
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
|
</select>
|
|
}
|
|
|
|
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
|
|
// #7): unsubscribe (files kept — the D6 default), remove feed + files
|
|
// entirely, or cancel. Reachable only for admins (the button that opens
|
|
// it renders for admins alone).
|
|
templ PodcastUnsubscribeModal(data PodcastDetailData) {
|
|
<div
|
|
id="podcast-unsub-modal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
x-show="unsubOpen"
|
|
x-cloak
|
|
@click.self="unsubOpen = false"
|
|
>
|
|
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
|
<div class="p-6 border-b" style="border-color: var(--border);">
|
|
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
|
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
|
|
</div>
|
|
<div class="p-6 flex flex-col gap-2">
|
|
<button
|
|
class="btn btn-secondary w-full"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-swap="none"
|
|
@click="unsubOpen = false"
|
|
>
|
|
<span>Unsubscribe — files kept</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary w-full"
|
|
style="color: var(--status-danger);"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
|
hx-swap="none"
|
|
@click="unsubOpen = false"
|
|
>
|
|
<span>Remove feed — delete episodes and files</span>
|
|
</button>
|
|
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|