// Podcast view types + mappers shared by the JSON API and the SSR // partials (plan §8/§9). One shape per concept so the htmx partials and // the API can never drift. package handlers import ( "sort" "time" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) // Feed is the API-facing feed shape (subscription fields attached when // the caller has one). type Feed struct { ID string `json:"id"` LibraryID string `json:"library_id"` FeedUrl string `json:"feed_url"` PodcastGuid string `json:"podcast_guid,omitempty"` Title string `json:"title"` Description string `json:"description,omitempty"` Host string `json:"host,omitempty"` Publisher string `json:"publisher,omitempty"` Language string `json:"language,omitempty"` Tags []string `json:"tags,omitempty"` Explicit bool `json:"explicit"` ArtworkPath string `json:"artwork_path,omitempty"` FeedType string `json:"feed_type,omitempty"` Medium string `json:"medium,omitempty"` IsComplete bool `json:"is_complete"` LastFetchedAt string `json:"last_fetched_at,omitempty"` // Subscription-owned (rendered when present). DisplayName string `json:"display_name,omitempty"` AutoDownload string `json:"auto_download,omitempty"` AutoDownloadCount int `json:"auto_download_count,omitempty"` } // PodcastEpisodeView is the episode-list row: media facts + the // download lifecycle the list renders inline (D8/D19). type PodcastEpisodeView struct { ID string `json:"id"` FeedID string `json:"feed_id"` Title string `json:"title"` Subtitle string `json:"subtitle,omitempty"` Description string `json:"description,omitempty"` Authors []string `json:"authors,omitempty"` Hosts []string `json:"hosts,omitempty"` Guests []string `json:"guests,omitempty"` Date string `json:"date_published,omitempty"` FeedPosition int `json:"feed_position"` Season int `json:"season_number,omitempty"` Episode int `json:"episode_number,omitempty"` Duration int `json:"declared_duration_seconds,omitempty"` MimeType string `json:"mime_type,omitempty"` CoverImage string `json:"cover_image_path,omitempty"` EnclosureURL string `json:"enclosure_url,omitempty"` // Download lifecycle. DownloadState string `json:"download_state"` DownloadError string `json:"download_error,omitempty"` DownloadAttempts int `json:"download_attempts"` Downloaded bool `json:"downloaded"` FileSize int64 `json:"file_size,omitempty"` // Progress (from the shared reading_progress machinery). ProgressPercent float64 `json:"progress_percent,omitempty"` } // episodeView maps a media_items row (podcast episode) onto the view. func episodeView(ep *database.MediaItems) PodcastEpisodeView { v := PodcastEpisodeView{ ID: uuidString(ep.ID), FeedID: uuidString(ep.SourceFeedID), Title: ep.Title, Subtitle: ep.Subtitle.String, Description: ep.Description.String, Authors: ep.Authors, Hosts: ep.Hosts, Guests: ep.Guests, FeedPosition: int(ep.FeedPosition.Int32), Season: int(ep.SeasonNumber.Int32), Episode: int(ep.EpisodeNumber.Int32), Duration: int(ep.DeclaredDurationSeconds.Int32), MimeType: ep.MimeType.String, CoverImage: ep.CoverImagePath.String, EnclosureURL: ep.EnclosureUrl.String, DownloadState: ep.DownloadState, DownloadError: ep.DownloadError.String, DownloadAttempts: int(ep.DownloadAttempts), Downloaded: ep.DownloadState == "downloaded", FileSize: ep.FileSize.Int64, } if ep.DatePublished.Valid { v.Date = ep.DatePublished.Time.Format("2006-01-02") } if !ep.FileSize.Valid { v.FileSize = 0 } return v } // feedWithSubscription merges the feed row + the caller's subscription // (empty sub → bare feed). func feedWithSubscription(feed *database.PodcastFeeds, sub database.PodcastSubscriptions, subFound bool) Feed { f := Feed{ ID: uuidString(feed.ID), LibraryID: uuidString(feed.LibraryID), FeedUrl: feed.FeedUrl, PodcastGuid: feed.PodcastGuid.String, Title: feed.Title, Description: feed.Description.String, Host: feed.Host.String, Publisher: feed.Publisher.String, Language: feed.Language.String, Tags: feed.Tags, Explicit: feed.Explicit, ArtworkPath: feed.ArtworkPath.String, FeedType: feed.FeedType.String, Medium: feed.Medium.String, IsComplete: feed.IsComplete, LastFetchedAt: timeString(feed.LastFetchedAt), } if subFound { f.DisplayName = sub.DisplayName.String f.AutoDownload = sub.AutoDownload f.AutoDownloadCount = int(sub.AutoDownloadCount.Int32) } return f } // countGaps counts un-downloaded episodes that have a downloaded // episode AFTER them in list order — the D8 "middle episodes missing" // definition that drives the gap banner. func countGaps(eps []PodcastEpisodeView) int { seenDownloaded := false gaps := 0 for i := len(eps) - 1; i >= 0; i-- { if eps[i].Downloaded { seenDownloaded = true } else if seenDownloaded { gaps++ } } return gaps } func countDownloaded(eps []PodcastEpisodeView) int { n := 0 for _, e := range eps { if e.Downloaded { n++ } } return n } // facetValuesFor derives the /podcasts facet bar values (D18) from the // subscription list — feed counts are small; no facet SQL. func facetValuesFor(feeds []podcastFeedSummary) map[string][]string { hosts := map[string]bool{} tags := map[string]bool{} langs := map[string]bool{} media := map[string]bool{} for _, f := range feeds { if f.Host != "" { hosts[f.Host] = true } for _, t := range f.Tags { tags[t] = true } if f.Language != "" { langs[f.Language] = true } if f.Medium != "" { media[f.Medium] = true } } return map[string][]string{ "hosts": sortedKeys(hosts), "tags": sortedKeys(tags), "language": sortedKeys(langs), "medium": sortedKeys(media), } } func sortedKeys(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { out = append(out, k) } sort.Strings(out) return out } // uuidString renders a pgtype.UUID (empty string when NULL). func uuidString(id pgtype.UUID) string { if !id.Valid { return "" } return uuid.UUID(id.Bytes).String() } // timeString renders a timestamp (RFC3339; empty when NULL). func timeString(t pgtype.Timestamptz) string { if !t.Valid { return "" } return t.Time.Format(time.RFC3339) } // pgtypeText (handlers-local) — service has its own. func pgtypeText(s string) pgtype.Text { if s == "" { return pgtype.Text{} } return pgtype.Text{String: s, Valid: true} }