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:
1 parent
d5dbaa48ea
commit
cc0e9d7f91
16 files changed
+946
-538
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) + `
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user