feat(podcasts): the episodes authoring door + curated chain order

"Newest episodes from my selected podcasts" is now buildable in the UI,
end to end — the user could not find ANY podcast-related filter surface
because none existed: the New Collection modal could only create
book-scope smart collections, and the engine's episodes scope (item 3.1)
had no authoring door.

The scope door:
- The create modal's Smart card gains a Rules-scope segmented control
  (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart
  means "match books by rules". Choosing Episodes seeds an EMPTY
  episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the
  collection is born episode-scoped: the editor opens with the episode
  vocabulary (feed/downloaded/played/published date) and membership
  runs the episode engine from the first rule.
- ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay
  invalid) — the seed's legal shape. The root arrives at depth 1 by
  call convention, hence the depth>1 exemption.
- The composers define the empty-tree semantics: zero children matches
  NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring
  EvaluateSmartCollection's zero-enabled-rules convention. Without the
  guard the composed query degrades to the bare scope head and a fresh
  collection would swallow every episode in scope — pinned by
  TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0)
  and TestEmptyRootTreeSeedsEpisodesScope.

The curated chain order (plan §10 R2, user need: the briefing plays
NEWEST-first):
- view_settings.chain_order ("native"/"newest"/"oldest"/"reverse")
  stored on the collection — curated content, not a player-side knob.
- PUT /api/collections/:id/view-settings merges the key into the
  stored JSONB (a blind write would clobber the playlist toggles).
- buildSmartCollectionSequenceContext routes OrderEpisodes through
  handlers.CollectionChainOrder; the detail header's Play-as-Sequence
  gains the order select next to it (PlayCollectionButton), which
  PUTs and reloads. Pinned by chain_follows_the_curated_order
  (reverse flips the chain indexes and hops).

Caught by the new test, a latent clobber fixed: UpdateCollection
treated a NIL view_settings payload as "write NULL" — any edit that
carried no settings dropped every stored key (chain_order, playlist
toggles from API clients, imported provenance). Nil now means
untouched, the same convention filterTree already had.

Composer guards also cover ComposedCount, so the builder's live count
reads the same semantics. Tests: collectionChainOrder unit matrix,
empty-root parse unit pin, two integration pins (seed matches nothing;
curated order drives the chain and survives whole-map edits).
This commit is contained in:
John O'Keefe committed 2026-10-31 13:39:24 -04:00
1 parent d5dbaa48ea
commit cc0e9d7f91
16 files changed
+946 -538

No files matched your search

+84 -2
View File
@@ -86,10 +86,10 @@ func parseRequestFilterTree(payload any) ([]byte, error) {
}
type UpdateCollectionRequest struct {
Name string `form:"name" json:"name" validate:"required"`
Name string `form:"name" json:"name" validate:"required"`
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
// (AND). Empty/invalid keeps the stored value.
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
// The unified filter tree (item 3.2): when present the collection's
// smart membership runs through the qf engine. INVALID trees are a
// 400 (ParseFilterTree at the handler); a tree supersedes
@@ -547,6 +547,88 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusCreated, response)
}
// ValidChainOrders — the collection chain's listening-order vocabulary
// (plan §10 R2: a collection's playback order is CURATED on the
// collection, not a player-side knob). Mirrors OrderEpisodes' orders;
// "" means native (the pre-chain-order default).
var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true}
// CollectionChainOrder resolves a collection row's curated chain order
// from its view_settings JSONB — "" (native) when absent/unknown. The
// episode chain producer routes OrderEpisodes through this; the
// view-settings PUT writes the key (SetCollectionChainOrder).
func CollectionChainOrder(viewSettings []byte) string {
if len(viewSettings) == 0 {
return ""
}
var vs map[string]interface{}
if json.Unmarshal(viewSettings, &vs) != nil {
return ""
}
if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] {
return s
}
return ""
}
// SetCollectionChainOrder — PUT /api/collections/:id/view-settings with
// {"chain_order": "..."}: merges the key into the collection's stored
// view_settings (the playlist toggles share that JSONB — a blind write
// would clobber them). The episode chain producer
// (buildSmartCollectionSequenceContext) honors it.
func (h *CollectionHandler) SetCollectionChainOrder(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
var req struct {
ChainOrder string `json:"chain_order"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if !ValidChainOrders[req.ChainOrder] {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "chain_order must be one of: native, newest, oldest, reverse"})
}
collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
}
settings := map[string]interface{}{}
if len(collection.ViewSettings) > 0 {
if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"})
}
}
if req.ChainOrder == "" || req.ChainOrder == "native" {
// Both spellings mean the default — drop the key entirely so the
// stored doc stays clean.
delete(settings, "chain_order")
} else {
settings["chain_order"] = req.ChainOrder
}
blob, err := json.Marshal(settings)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewSettings: blob,
}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"chain_order": req.ChainOrder,
"view_settings": settings,
})
}
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -0,0 +1,30 @@
package handlers
import "testing"
// The collection chain's curated order (plan §10 R2): the resolver
// reads view_settings.chain_order, tolerates absent/corrupt docs, and
// refuses unknown orders (they mean native).
func TestCollectionChainOrder(t *testing.T) {
cases := []struct {
name string
vs string
want string
}{
{"absent doc", "", ""},
{"empty object", `{}`, ""},
{"reverse", `{"chain_order":"reverse"}`, "reverse"},
{"newest", `{"chain_order":"newest"}`, "newest"},
{"merged with toggles", `{"hide_finished":true,"chain_order":"oldest"}`, "oldest"},
{"unknown order means native", `{"chain_order":"shuffle"}`, ""},
{"non-string means native", `{"chain_order":3}`, ""},
{"corrupt doc means native", `not json`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CollectionChainOrder([]byte(tc.vs)); got != tc.want {
t.Fatalf("CollectionChainOrder(%s) = %q, want %q", tc.vs, got, tc.want)
}
})
}
}
+3
View File
@@ -18,6 +18,9 @@ func registerCollectionsRoutes(cfg *Config) {
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
collections.PUT("/:id/match-mode", cfg.CollectionHandler.SetMatchMode)
// The chain's listening order lives ON the collection (plan §10 R2):
// merges into view_settings; the episode chain producer honors it.
collections.PUT("/:id/view-settings", cfg.CollectionHandler.SetCollectionChainOrder)
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
+11
View File
@@ -2,6 +2,7 @@ package router
import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
@@ -270,6 +271,15 @@ func visibleLibraryIDsFor(c *echo.Context, cfg *Config) (map[string]bool, []pgty
// collectionDataFromRow builds the template collection payload shared by
// the collection detail page and its books-grid partial.
func collectionDataFromRow(collection database.Collections, collectionID string, isSmart bool, bookCount int) templates.CollectionData {
chainOrder := ""
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if s, ok := vs["chain_order"].(string); ok {
chainOrder = s
}
}
}
return templates.CollectionData{
ID: collectionID,
Name: collection.Name,
@@ -279,6 +289,7 @@ func collectionDataFromRow(collection database.Collections, collectionID string,
IsSystem: collection.IsSystemCollection.Bool,
IsSmart: isSmart,
MatchMode: collection.RuleMatchMode,
ChainOrder: chainOrder,
BookCount: bookCount,
}
}
+4 -1
View File
@@ -374,7 +374,10 @@ func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *dat
if len(ordered) == 0 {
return nil
}
services.OrderEpisodes(ordered, "native",
// Plan §10 R2: the collection's CURATED listening order (stored in
// view_settings.chain_order, set from the detail header) — not a
// player-side knob. "" / native / unknown all mean feed order.
services.OrderEpisodes(ordered, handlers.CollectionChainOrder(coll.ViewSettings),
func(e seqEp) int32 { return e.pos },
func(e seqEp) string { return e.date },
)
+11 -2
View File
@@ -189,6 +189,17 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
viewSettings = merged
}
}
// Same convention as filterTree: a NIL view_settings payload
// means untouched — keep the stored doc. Without this, an edit
// that carries no settings wrote NULL over the row and silently
// dropped every stored key (chain_order, imported provenance);
// the 2026-10-31 chain-order test pinned exactly this clobber.
if viewSettings == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
viewSettings = stored
}
}
}
if !ValidRuleMatchMode(ruleMatchMode) {
ruleMatchMode = existingMode
@@ -493,8 +504,6 @@ func IsCollectionSmart(coll database.Collections) bool {
return len(rules) > 0
}
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
// tree-bearing collections evaluate through the unified filter engine;
// legacy rule arrays keep per-item evaluation until rewritten. One
+14
View File
@@ -883,6 +883,15 @@ func composedQueryBlock(qBind, exactBind string) string {
// Sqlc embed for tree collections; ordering matches the legacy
// candidate query (title ASC).
func (s *SearchService) ComposedLiveMedia(ctx context.Context, userID, libraryID pgtype.UUID, visibleIDs []pgtype.UUID, tree *FilterTree) ([]database.MediaItems, error) {
// A rules-LESS tree (root with zero children — legal since the
// episodes scope door seeds one at create-modal time) matches
// NOTHING, mirroring the legacy engine's zero-enabled-rules → empty
// convention (EvaluateSmartCollection). Without this guard the
// composed query degrades to the bare scope head below and a fresh
// Episodes collection would swallow every episode in the library.
if len(tree.Children) == 0 {
return []database.MediaItems{}, nil
}
b := newFilterBuilder(userID)
head := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b)
visibleClause := ""
@@ -913,6 +922,11 @@ ORDER BY mi.title ASC`
// ComposedCount — the live match count for the builder (rules + scope,
// no pagination): a true COUNT(*), not the page length.
func (s *SearchService) ComposedCount(ctx context.Context, userID, libraryID pgtype.UUID, tree *FilterTree) (int, error) {
// Same empty-tree convention as ComposedLiveMedia: rules-less means
// zero matches, not "everything".
if len(tree.Children) == 0 {
return 0, nil
}
b := newFilterBuilder(userID)
libBind := b.bind(libraryID)
whereSQL := b.headWhere(libraryID, tree.FilterScope()) + treeWhere(tree, b) + `
+17 -10
View File
@@ -222,14 +222,14 @@ var filterOps = map[string]FilterOpWire{
// FilterFieldWire is one field's presentation entry (the registry's
// wire face): the frontend menu is scope-filtered OFF this list.
type FilterFieldWire struct {
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
ValueKind string `json:"valueKind"`
Ops []FilterOpWire `json:"ops"`
DefaultOp string `json:"defaultOp"`
Suggest bool `json:"suggest"`
Options []string `json:"options,omitempty"`
Key string `json:"key"`
Label string `json:"label"`
Group string `json:"group"`
ValueKind string `json:"valueKind"`
Ops []FilterOpWire `json:"ops"`
DefaultOp string `json:"defaultOp"`
Suggest bool `json:"suggest"`
Options []string `json:"options,omitempty"`
}
// valueKinds — what the value cell renders (the registry's Kind mapped
@@ -633,7 +633,15 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
if g.Mode != "all" && g.Mode != "any" {
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
}
if len(g.Children) == 0 {
// The ROOT arrives as depth 1 (call convention below), so "depth > 1"
// exempts exactly the root: an EMPTY ROOT is legal as of the episodes
// scope door — the create modal seeds
// `{"scope":"episodes","mode":"all","children":[]}` so a fresh
// Episodes smart collection has a stored scope before its first
// rule. Nested empty groups stay invalid, and the composers define
// the semantics: zero children matches nothing (mirrors
// EvaluateSmartCollection's zero-rules convention).
if len(g.Children) == 0 && depth > 1 {
return fmt.Errorf("filter group has no children")
}
for _, c := range g.Children {
@@ -650,7 +658,6 @@ func validateGroup(g *FilterChild, depth int, scope string) error {
return nil
}
// numericParsers map each numeric field's wire values to the Go value
// bound into SQL — validation and clause-building share one parser per
// field, so a value that passes validation always binds.
+25
View File
@@ -171,3 +171,28 @@ func TestFilterFieldsByGroupOrdering(t *testing.T) {
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
}
}
// The episodes scope door (2026-10-31): the create modal seeds an EMPTY
// episodes root so a fresh Episodes smart collection has a stored scope
// before its first rule. The parser accepts an empty ROOT (nested empty
// groups stay invalid), and the composers define the semantics — zero
// children matches NOTHING, mirroring EvaluateSmartCollection's
// zero-rules convention (a bare scope head would match everything).
func TestEmptyRootTreeSeedsEpisodesScope(t *testing.T) {
tree, err := ParseFilterTree(`{"scope":"episodes","mode":"all","children":[]}`)
if err != nil {
t.Fatalf("ParseFilterTree(seed): %v", err)
}
if got := tree.FilterScope(); got != ScopeEpisodes {
t.Fatalf("FilterScope = %q, want %q", got, ScopeEpisodes)
}
if len(tree.Children) != 0 {
t.Fatalf("children = %d, want 0", len(tree.Children))
}
// Nested empty groups remain invalid.
nested := `{"mode":"all","children":[{"mode":"any","children":[]}]}`
if _, err := ParseFilterTree(nested); err == nil {
t.Fatal("nested empty group must stay invalid")
}
}