WHY: the /podcasts search box filtered feed CARDS only (title, display name, host, publisher, feed description). D14's episode search existed only as the /api/podcasts/search endpoint and was never wired into the page — searching 'Quarter' (present only in episode titles) cleared the grid while 'Trader' (feed metadata) survived. The page query now consults SearchMyPodcastEpisodes (subscription-scoped via RLS — the caller's identity) when a query is present; a feed surfaces when its OWN metadata OR any of its episodes matches, with an 'N matching episode(s)' badge on the card. PIN: SSR test with a needle that exists only in an episode title — the feed must surface with the match count.
441 lines
14 KiB
Go
441 lines
14 KiB
Go
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")
|
|
}
|
|
data, err := buildPodcastDetailData(c, cfg, user)
|
|
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")
|
|
}
|
|
data, err := buildPodcastDetailData(c, cfg, user)
|
|
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) (*templates.PodcastDetailData, error) {
|
|
actor := handlers.MustGetAuthenticatedUser(c)
|
|
ctx := c.Request().Context()
|
|
|
|
feedID, err := parsePodcastUUID(c.Param("id"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
|
|
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]
|
|
p := progressByItem[uuid.UUID(ep.ID.Bytes).String()]
|
|
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(),
|
|
ProgressPct: 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
|
|
})
|
|
}
|
|
|
|
return &templates.PodcastDetailData{
|
|
Feed: card,
|
|
Episodes: rows,
|
|
GapCount: gaps,
|
|
Order: order,
|
|
IsAdmin: actor.Role == "admin",
|
|
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
|
|
}, 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
|
|
}
|