feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)

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.
This commit is contained in:
John O'Keefe committed 2026-10-30 17:12:07 -04:00
1 parent cc56b9ef2a
commit 69773ec307
3 files changed
+159

No files matched your search

+90
View File
@@ -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
}