From 69773ec3075ab90d7d76a96eab087cbb205532a9 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 17:12:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20smart=20collections=20play=20?= =?UTF-8?q?as=20a=20sequence=20=E2=80=94=20the=20Daily=20Fiction=20accepta?= =?UTF-8?q?nce=20(item=202.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed ∧ <7d matches exactly today's episodes, and PLAY = today's episodes back-to-back. Two pieces were missing after item 2.4: - The chain builder only read static membership (collection_items) — a smart collection has none. buildSmartCollectionSequenceContext now evaluates the rules LIVE for the requesting user (mode from the collection, family lens via the visible set) and chains the downloaded episode matches in NATIVE feed order — the collection's computed order is content (R1: never written back; OrderEpisodes is the order chokepoint per R3). A match that aged out of the briefing (played since, published beyond the window) falls back to the feed queue instead of breaking the page. - The smart-detail branch rendered match cards as books: episode matches are now flagged (IsEpisode + download state) so they carry the play affordance and the header's Play-as-Sequence button; feed titles stay empty on smart cards for now (batch lookup is polish, not acceptance). Pinned by TestPodcastSmartCollections' acceptance subtests: the briefing starts at index 1/2 and chains into the second with the queue param on both hops; the order control stays hidden (computed order); a stale match falls back to the feed queue honestly. --- cmd/server/tests/podcast_test.go | 58 ++++++++++++++++++++ internal/router/helpers.go | 11 ++++ internal/router/reader.go | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 5084520b..7a2c0b8d 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -812,6 +812,9 @@ func TestPodcastSmartCollections(t *testing.T) { feedAID := uuid.UUID(feedA.ID.Bytes).String() + // Sequence config rides a data-json attribute — templ escapes quotes. + esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } + // Smart collection with the briefing's AND shape, created through // the real API. code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", @@ -892,6 +895,61 @@ func TestPodcastSmartCollections(t *testing.T) { assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it") assert.NotContains(t, string(body), "SmartColl B Ep") }) + + // ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ---- + + // One more briefing match so the chain has a next hop. + aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "SmartColl A New Two", 5) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000, + date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID) + + t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) { + // Back to ALL: today's unplayed feed-A episodes = aNew + aNew2, + // chained in native feed order. Playing the FIRST match (the + // header's Play-as-Sequence target) must chain into the second. + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`) + require.Equal(t, http.StatusOK, code) + + // The detail page (smart branch, All Libraries view) renders + // episode cards with the play affordance + the header button. + // (The library-scoped view filters matches to that library — the + // switcher contract, same as books.) + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil) + req.Header.Set("Authorization", "Bearer "+regToken) + req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"}) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + html := buf.String() + assert.Contains(t, html, "Play as Sequence", "smart collections play as a sequence too") + assert.Contains(t, html, "?queue=collection:"+collID, "smart episode cards chain through the queue param") + + // Start from the first briefing match (feed position 1): index 1 + // of 2, next = the second, both hops carrying the queue param. + code, body = podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html = string(body) + assert.Contains(t, html, esc(`"source":"collection"`)) + assert.Contains(t, html, esc(`"episode_index":1`)) + assert.Contains(t, html, esc(`"index_total":2`), "exactly the briefing set: aOld fails <7d, aPlayed fails unplayed, aPending is un-downloaded, bEp is RLS-dropped") + assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`)) + assert.NotContains(t, html, `id="player-order-select"`, "computed order — the order control stays hidden (R1)") + }) + + t.Run("acceptance_stale_briefing_match_falls_back", func(t *testing.T) { + // aPlayed is DOWNLOADED but no longer matches the briefing + // (played). Opening its reader with the queue param: the smart + // chain can't place it → feed-queue fallback, not a broken page. + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(aPlayed.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + assert.Contains(t, string(body), esc(`"source":"feed"`), "a match that aged out of the briefing falls back to the feed queue") + }) } // ---- download worker: state machine, idempotency, file layout ---- diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 20777105..0fe2fa1f 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -308,6 +308,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, return nil, isSmart, "books_load_error" } books = handlers.BookCardsFromMediaItems(matches, libTypes) + // Episode-awareness for smart matches (item 2.5): episode cards + // carry the play affordance (chain via ?queue=collection:). + // BookCardsFromMediaItems preserves match order, so a parallel + // walk is enough; feed titles stay empty here (batch lookup is + // polish, not acceptance). + for i := range books { + if i < len(matches) && matches[i].Source == "podcast_episode" { + books[i].IsEpisode = true + books[i].DownloadState = matches[i].DownloadState + } + } } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { dashboardSvc := services.NewDashboardService(cfg.Queries) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) diff --git a/internal/router/reader.go b/internal/router/reader.go index c7f2588f..51c05cf3 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -282,6 +282,13 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database if err != nil { return nil } + // Smart collections compute their membership live (R1: computed + // order, never written back) — evaluate the rules for THIS viewer + // and chain the downloaded episode matches in native feed order + // (R3: every listening order routes through OrderEpisodes). + if len(coll.AutoAssignRules) > 0 { + return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll) + } // Downloaded episodes in collection order (SQL orders; RLS scopes // the join — unsubscribed feeds drop out and the chain skips them). eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true}) @@ -318,3 +325,86 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database } return seq } + +// buildSmartCollectionSequenceContext chains a SMART collection's live +// membership (plan item 2.5 acceptance: "Daily Fiction" → Play = +// today's episodes back-to-back). The rules evaluate for the requesting +// user (mode from the collection; family lens via the visible set); +// episode matches that are downloaded chain in native feed order. +// Books and un-downloaded matches never enter the queue (invariant 6). +func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, coll database.Collections) *templates.PodcastSequenceContext { + ctx := c.Request().Context() + colService := cfg.CollectionHandler.CollectionService() + visibleIDs, err := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if err != nil { + visibleIDs = nil + } + matches, err := colService.EvaluateSmartCollection(ctx, colService.ParseRules(coll.AutoAssignRules), services.SmartEvalOptions{ + VisibleIDs: visibleIDs, + MatchMode: coll.RuleMatchMode, + ViewerUserID: userID, + }) + if err != nil { + return nil + } + + type seqEp struct { + id string + title string + date string + pos int32 + } + ordered := make([]seqEp, 0, len(matches)) + currentID := uuid.UUID(item.ID.Bytes).String() + for _, m := range matches { + if m.Source != "podcast_episode" || m.DownloadState != "downloaded" { + continue + } + date := "" + if m.DatePublished.Valid { + date = m.DatePublished.Time.Format("2006-01-02") + } + ordered = append(ordered, seqEp{ + id: uuid.UUID(m.ID.Bytes).String(), + title: m.Title, + date: date, + pos: m.FeedPosition.Int32, + }) + } + if len(ordered) == 0 { + return nil + } + services.OrderEpisodes(ordered, "native", + func(e seqEp) int32 { return e.pos }, + func(e seqEp) string { return e.date }, + ) + + idx := -1 + for i, ep := range ordered { + if ep.id == currentID { + idx = i + break + } + } + if idx < 0 { + return nil + } + + collID := uuid.UUID(coll.ID.Bytes).String() + queueParam := "?queue=collection:" + collID + seq := &templates.PodcastSequenceContext{ + FeedURL: "/collections/" + collID, + FeedTitle: coll.Name, + Source: "collection", + EpisodeIndex: idx + 1, + IndexTotal: len(ordered), + } + if idx > 0 { + seq.PrevURL = "/readers/" + ordered[idx-1].id + queueParam + } + if idx < len(ordered)-1 { + seq.NextURL = "/readers/" + ordered[idx+1].id + queueParam + seq.NextTitle = ordered[idx+1].title + } + return seq +}