package router // Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid + // add-feed wizard) and /podcasts/:id (feed detail with the inline // download lifecycle). Rendering ownership: ALL content is // templ-rendered server data; htmx swaps partials; the sanctioned JS is // the modal toggle + event delegation in web/src/podcasts.ts. import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/utils" "bookhoard/templates" "bytes" "encoding/json" "fmt" "net/http" "sort" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) func registerPodcastPages(cfg *Config) { pages := cfg.Echo.Group("", createJWTMiddleware(cfg)) pages.GET("/podcasts", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } data, err := buildPodcastsPageData(c, cfg) if err != nil { data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"} } var buf bytes.Buffer if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) pages.GET("/podcasts/:id", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } fid, ferr := parsePodcastUUID(c.Param("id")) if ferr != nil { return c.NoContent(http.StatusNotFound) } data, err := buildPodcastDetailData(c, cfg, user, fid) if err != nil { var buf bytes.Buffer _ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf) return c.HTML(http.StatusNotFound, buf.String()) } var buf bytes.Buffer if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // Episode list partial — the htmx refresh unit (poll while // downloads are active; podcast:changed events swap it too). pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } fid, ferr := parsePodcastUUID(c.Param("id")) if ferr != nil { return c.NoContent(http.StatusNotFound) } data, err := buildPodcastDetailData(c, cfg, user, fid) if err != nil { return c.NoContent(http.StatusNotFound) } var buf bytes.Buffer if templates.HasActiveDownloads(data.Episodes) { err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf) } else { err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf) } if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) } // buildPodcastsPageData assembles the /podcasts page: subscription // cards, facet values, server-side filters (q/host/tag/language). func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) { user := handlers.MustGetAuthenticatedUser(c) ctx := c.Request().Context() libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID) data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0} if err != nil || len(libs) == 0 { return data, err } for _, lib := range libs { data.Libraries = append(data.Libraries, templates.LibraryData{ ID: uuid.UUID(lib.ID.Bytes).String(), Name: lib.Name, TypeName: "podcasts", }) } feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID) if err != nil { return data, err } // Continue aggregation (plan 2.5): first in-progress episode per // feed (the query is already most-recent-first). continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow) if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil { for i := range continueRows { row := &continueRows[i] key := uuid.UUID(row.SourceFeedID.Bytes).String() if _, ok := continueByFeed[key]; !ok { continueByFeed[key] = row } } } for i := range feeds { row := &feeds[i] total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID) downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID) data.Feeds = append(data.Feeds, templates.PodcastFeedCard{ ID: uuid.UUID(row.ID.Bytes).String(), LibraryID: uuid.UUID(row.LibraryID.Bytes).String(), Title: row.Title, DisplayName: row.DisplayName.String, Description: firstLine(row.Description.String, 220), Host: row.Host.String, Publisher: row.Publisher.String, Language: row.Language.String, Tags: row.Tags, Explicit: row.Explicit, ArtworkPath: row.ArtworkPath.String, FeedType: row.FeedType.String, Medium: row.Medium.String, IsComplete: row.IsComplete, FeedURL: row.FeedUrl, LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)), SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)), EpisodeTotal: int(total), DownloadedN: int(downloaded), UndownloadedN: int(total) - int(downloaded), NewEpisodes: total-downloaded > 0, AutoDownload: row.AutoDownload, AutoDownloadCount: int(row.AutoDownloadCount.Int32), Subscribed: true, }) if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil { card := &data.Feeds[len(data.Feeds)-1] card.HasContinue = true card.ContinueTitle = cont.Title card.ContinueEpisode = int(cont.FeedPosition.Int32) card.ContinuePercent = cont.Percentage.Float64 card.ContinueLastRead = cont.LastReadAt.Time } } // Facet values from the unfiltered subscription list (D18). allData := *data data.Facets = facetValuesFromCards(allData.Feeds) // Server-side filters. q := strings.ToLower(strings.TrimSpace(c.QueryParam("q"))) host := c.QueryParam("host") tag := c.QueryParam("tag") lang := c.QueryParam("language") // Episode-level matches (issue #8 / D14): a query also hits episode // titles + descriptions — the feed surfaces with a match count. // SearchMyPodcastEpisodes is subscription-scoped via RLS (the // caller's identity). Only consult it when a query is present. matchesByFeed := make(map[string]int) if q != "" { if eps, err := cfg.Queries.SearchMyPodcastEpisodes(ctx, q); err == nil { for i := range eps { key := uuid.UUID(eps[i].SourceFeedID.Bytes).String() matchesByFeed[key]++ } } } filtered := data.Feeds[:0:0] for _, f := range data.Feeds { if q != "" { hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description) matched := matchesByFeed[f.ID] if !strings.Contains(hay, q) && matched == 0 { continue } } if host != "" && f.Host != host { continue } if lang != "" && !strings.EqualFold(f.Language, lang) { continue } if tag != "" { found := false for _, t := range f.Tags { if strings.EqualFold(t, tag) { found = true break } } if !found { continue } } filtered = append(filtered, f) } if q != "" { for i := range filtered { filtered[i].EpisodeMatches = matchesByFeed[filtered[i].ID] } } data.Feeds = filtered data.Query = q data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds) return data, nil } // buildPodcastDetailData assembles the feed-detail page + episode list // partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows → // 404 semantics — no Go visibility plumbing, D20). func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, feedID pgtype.UUID) (*templates.PodcastDetailData, error) { actor := handlers.MustGetAuthenticatedUser(c) ctx := c.Request().Context() feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID) if err != nil { return nil, err } sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{ UserID: actor.ID, FeedID: feedID, }) eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID) if err != nil { return nil, err } progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{ UserID: actor.ID, SourceFeedID: feedID, }) progressByItem := make(map[string]struct { Pct float64 LastRead pgtype.Timestamptz }, len(progressRows)) for _, p := range progressRows { progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct { Pct float64 LastRead pgtype.Timestamptz }{p.Percentage.Float64, p.LastReadAt} } total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID) downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID) subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID) card := templates.PodcastFeedCard{ ID: uuid.UUID(feed.ID.Bytes).String(), LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(), Title: feed.Title, DisplayName: sub.DisplayName.String, 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, FeedURL: feed.FeedUrl, LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone), EpisodeTotal: int(total), DownloadedN: int(downloaded), UndownloadedN: int(total) - int(downloaded), NewEpisodes: total-downloaded > 0, AutoDownload: sub.AutoDownload, AutoDownloadCount: int(sub.AutoDownloadCount.Int32), Subscribed: sub.ID.Valid, } rows := make([]templates.PodcastEpisodeRow, 0, len(eps)) gaps := 0 seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order for i := len(eps) - 1; i >= 0; i-- { ep := &eps[i] downloadedEp := ep.DownloadState == "downloaded" if !downloadedEp && seenDownloadedFrom { gaps++ } if downloadedEp { seenDownloadedFrom = true } } // Forward pass for the actual rows (native order — the list keeps // feed order; the ORDER selector drives the sequence endpoint, and // the order control re-renders this list via the override applied // below). order := podcastOrderForFeed(cfg, c, feedID.String()) for i := range eps { ep := &eps[i] rowID := uuid.UUID(ep.ID.Bytes).String() p := progressByItem[rowID] dateDisplay := "" if ep.DatePublished.Valid { dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006") } rows = append(rows, templates.PodcastEpisodeRow{ ID: uuid.UUID(ep.ID.Bytes).String(), FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(), Title: ep.Title, Description: firstLine(ep.Description.String, 200), Date: dateDisplay, Position: int(ep.FeedPosition.Int32), Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)), Size: templates.FormatEpisodeSize(ep.FileSize.Int64), PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(), ListenPct: p.Pct, Downloaded: ep.DownloadState == "downloaded", DownloadState: ep.DownloadState, DownloadError: ep.DownloadError.String, }) } // Order override reshapes the LIST too (the sequence endpoint is the // strict downloaded-only view; the list shows everything in the // chosen order so pending episodes stay visible). if order == "oldest" || order == "newest" { sort.SliceStable(rows, func(a, b int) bool { ka := rows[a].Date + itoaPad(rows[a].Position) kb := rows[b].Date + itoaPad(rows[b].Position) if order == "newest" { return ka > kb } return ka < kb }) } detail := &templates.PodcastDetailData{ Feed: card, Episodes: rows, GapCount: gaps, Order: order, IsAdmin: actor.Role == "admin", IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook", SubscriberCount: int(subscriberCount), } // Live byte progress for in-flight downloads (issue #1): read the // worker's snapshot; rows not actively downloading render states. if cfg.PodcastWorker != nil { for i := range detail.Episodes { row := &detail.Episodes[i] if row.DownloadState != "downloading" { continue } if recv, tot, ok := cfg.PodcastWorker.EpisodeProgress(pgtype.UUID{Bytes: uuid.MustParse(row.ID), Valid: true}); ok { row.ProgressKnown = true row.ProgressReceived = recv row.ProgressTotal = tot if tot > 0 { row.ProgressPct = float64(recv) / float64(tot) * 100 } } } } return detail, nil } // podcastOrderForFeed reads the caller's order override (D5). func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string { user := handlers.MustGetAuthenticatedUser(c) row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{ UserID: user.ID, SettingKey: "podcast_order:" + feedID, }) if err != nil { return "native" } var v struct { Order string `json:"order"` } if err := json.Unmarshal(row, &v); err != nil { return "native" } switch v.Order { case "oldest", "newest": return v.Order } return "native" } // facetValuesFromCards derives the filter bar values. func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string { hosts, tags, langs := map[string]bool{}, map[string]bool{}, 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 } } return map[string][]string{ "hosts": sortedSet(hosts), "tags": sortedSet(tags), "language": sortedSet(langs), } } func sortedSet(set map[string]bool) []string { out := make([]string, 0, len(set)) for k := range set { out = append(out, k) } sort.Strings(out) return out } func firstLine(s string, max int) string { s = strings.TrimSpace(s) if i := strings.IndexAny(s, "\n\r"); i > 0 { s = s[:i] } if len(s) > max { s = s[:max] + "…" } return s } func pgTimeDisplay(t pgtype.Timestamptz, tz string) string { if !t.Valid { return "" } return utils.FormatTimeInTimezone(t.Time, tz) } func itoaPad(n int) string { return fmt.Sprintf("%06d", n) } // parsePodcastUUID parses an id path param (handlers.parseUUID is // package-private). func parsePodcastUUID(s string) (pgtype.UUID, error) { id, err := uuid.Parse(strings.TrimSpace(s)) if err != nil { return pgtype.UUID{}, err } return pgtype.UUID{Bytes: id, Valid: true}, nil }