diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index fcd11b68..bd4f9654 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -697,3 +697,36 @@ func TestPodcastOrderFormEncoding(t *testing.T) { assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)") assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected") } + +// ---- issue #8: page search hits EPISODE titles/descriptions too ---- + +func TestPodcastPageSearchEpisodes(t *testing.T) { + setup := setupTestServer(t) + lib, _ := createPodcastsLibrary(t, setup, "test PageSearch Podcasts") + // Feed title deliberately does NOT contain the needle. + feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/page-search.xml", + Title: "Trader Tales Show", + }) + require.NoError(t, err) + upsertTestEpisode(t, setup, feed, "ps-1", "Zarkorrin Blaster", 1) + + subscribeRaw(t, setup, getRegularUserID(t, setup.DB), feed.ID) + + // SSR page search: the needle exists only in an EPISODE title. + req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+"/podcasts?q=Zarkorrin", nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + body := buf.String() + + assert.Contains(t, body, "Trader Tales Show", + "PIN: a feed surfaces when an EPISODE matches the query, even when the feed's own metadata does not") + assert.Contains(t, body, "matching episode", + "the match count renders on the card") +} diff --git a/internal/router/podcast_page.go b/internal/router/podcast_page.go index 786f6873..7a69350a 100644 --- a/internal/router/podcast_page.go +++ b/internal/router/podcast_page.go @@ -168,11 +168,27 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag 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) - if !strings.Contains(hay, q) { + matched := matchesByFeed[f.ID] + if !strings.Contains(hay, q) && matched == 0 { continue } } @@ -196,6 +212,11 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag } 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) diff --git a/templates/podcast_types.go b/templates/podcast_types.go index 88396ba4..1a31d769 100644 --- a/templates/podcast_types.go +++ b/templates/podcast_types.go @@ -44,6 +44,11 @@ type PodcastFeedCard struct { 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 @@ -174,6 +179,14 @@ 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() { diff --git a/templates/podcasts.templ b/templates/podcasts.templ index ad6e9d46..25b38e08 100644 --- a/templates/podcasts.templ +++ b/templates/podcasts.templ @@ -179,6 +179,11 @@ templ PodcastFeedCardView(feed PodcastFeedCard) { { podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded" } + if feed.EpisodeMatches > 0 { + + { podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches) } + + } if feed.NewEpisodes { new episodes } diff --git a/templates/podcasts_templ.go b/templates/podcasts_templ.go index 00fdf372..1a481681 100644 --- a/templates/podcasts_templ.go +++ b/templates/podcasts_templ.go @@ -571,25 +571,44 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if feed.EpisodeMatches > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.EpisodeMatches) + " matching episode" + podcastPlural(feed.EpisodeMatches)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 184, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if feed.NewEpisodes { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "new episodes ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "new episodes ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if feed.IsComplete { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "complete ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "complete ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if feed.Explicit { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "explicit") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "explicit") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -613,12 +632,12 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var22 := templ.GetChildren(ctx) - if templ_7745c5c3_Var22 == nil { - templ_7745c5c3_Var22 = templ.NopComponent + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "