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.
232 lines
6.5 KiB
Go
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
|
|
}
|