The §8 surface, wired through main.go → router.Config → /api/podcasts (JWT). Scoping is the engine's (D20): lookups outside the caller's scope simply return no rows → 404; the only Go-level checks are explicit-argument authorization (AddFeed's library + allow_member_feeds gate) and the admin role on feed deletion. Endpoints: POST/GET /feeds (add: fetch+parse+upsert+subscribe, optional download_all; list: subscriptions + episode counts + facet values computed in Go — feed counts are tens), GET /feeds/:id (detail with download states + gap count), PUT/DELETE /subscription (the ONLY user-writable podcast state, D9; unsubscribe retains files per D6), POST /refresh, POST /episodes/:eid/download (+ episode_ids[] bulk and eid='all' = Download All, exactly the non-downloaded set), PUT /order (D5 per-user override in the reader_settings KV — podcast_order:<feed_id>; 'native' deletes the row so default and explicit choice are indistinguishable), GET /sequence (effective order — override applied, native fallback — DOWNLOADED-only with the gap list; this payload powers web chain mode and, per §15, the future media3 queue), GET /search (subscription-scoped feed + episode search covering un-downloaded and historical rows, D14), DELETE /feeds/:id (admin; best-effort file removal, cascade deletes rows). Gap definition (countGaps): an un-downloaded episode with a downloaded episode AFTER it in list order — the D8 'middle episodes missing' banner condition.
223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
// 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}
|
|
}
|