// 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 Downloaded bool DownloadState string // pending|queued|downloading|downloaded|failed DownloadError string // Playback progress (reading_progress; the row badge) — reuses the // ProgressPct field below when the row is downloading? No: keep the // two concerns separate (issue #1). ListenPct float64 // Live byte progress for rows in the downloading state (issue #1): // the worker's in-flight snapshot. ProgressKnown=false renders the // indeterminate bar; ProgressPct (0-100) drives the width when the // total is authoritative. ProgressKnown bool ProgressPct float64 ProgressReceived int64 ProgressTotal int64 } // 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) // SubscriberCount (issue #7): drives the last-subscriber // unsubscribe flow — the sole subscriber who is an admin gets the // three-choice modal (unsubscribe / remove feed / cancel). SubscriberCount int } // 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 }