Files
bookhoard/templates/podcast_types.go
T
John O'Keefe e3675536aa feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
2026-10-30 09:15:03 -04:00

232 lines
6.5 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)
SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering
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
}