Files
bookhoard/templates/podcast_types.go
T
John O'Keefe ffc80f7ba7 fix(podcasts): page search hits episode titles/descriptions (issue #8)
WHY: the /podcasts search box filtered feed CARDS only (title, display
name, host, publisher, feed description). D14's episode search existed
only as the /api/podcasts/search endpoint and was never wired into the
page — searching 'Quarter' (present only in episode titles) cleared the
grid while 'Trader' (feed metadata) survived.

The page query now consults SearchMyPodcastEpisodes (subscription-scoped
via RLS — the caller's identity) when a query is present; a feed
surfaces when its OWN metadata OR any of its episodes matches, with an
'N matching episode(s)' badge on the card.

PIN: SSR test with a needle that exists only in an episode title — the
feed must surface with the match count.
2026-10-29 19:49:39 -04:00

214 lines
5.7 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"
"sort"
"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
// EpisodeMatches (issue #8): when a search query is active, the
// number of the feed's episodes whose title/description matched —
// feeds match on EPISODES too, not just their own metadata.
EpisodeMatches int
// Continue aggregation (plan 2.5): the episode the user is midway
// through, most recent first across feeds.
HasContinue bool
ContinueTitle string
ContinueEpisode int
ContinuePercent float64
ContinueLastRead time.Time // section sort key (most recent first)
}
// 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 {
// ContinueFeeds: the in-progress subset, most recently listened
// first (plan 2.5) — split in the page builder (templ bodies can't
// declare locals).
ContinueFeeds []PodcastFeedCard
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)
}
// podcastPlural returns "" for 1 and "s" otherwise.
func podcastPlural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// 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)
}
// PodcastContinueFeeds returns the in-progress feeds, most recently
// listened first (plan 2.5). ≥95% progress counts as finished and is
// excluded (the continue query already applies that bound).
func PodcastContinueFeeds(feeds []PodcastFeedCard) []PodcastFeedCard {
out := make([]PodcastFeedCard, 0, len(feeds))
for _, f := range feeds {
if f.HasContinue {
out = append(out, f)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].ContinueLastRead.After(out[j].ContinueLastRead)
})
return out
}