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

+70
View File
@@ -1049,6 +1049,37 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) {
require.NoError(t, json.Unmarshal(body, &out))
assert.NotNil(t, out.Tree, "an edit that sends no tree keeps the stored one")
})
// Plan §10 R2 + the 2026-10-31 chain-order option: the collection's
// CURATED listening order (view_settings.chain_order) governs the
// chain. reverse flips the feed positions, so the episode that was
// 1/2 under native becomes 2/2 and gains a prev hop.
t.Run("chain_follows_the_curated_order", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT",
"/api/collections/"+collID+"/view-settings", `{"chain_order":"reverse"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// The edit-form save path must not clobber the merged key.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID,
`{"name":"test TreeColl `+marker+`","description":"order survives edits"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"chain_order":"reverse"`,
"view_settings merge keeps chain_order across whole-map writes")
// aNew is feed position 1: FIRST under native, LAST under reverse.
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(`"episode_index":2`),
"reverse order puts feed position 1 last")
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"the reversed chain hops back through feed position 5")
assert.NotContains(t, html, esc(`"next_url"`),
"the last episode of the chain has no next hop")
})
}
// ---- download worker: state machine, idempotency, file layout ----
@@ -2080,3 +2111,42 @@ func TestAdminDeleteEpisodeFiles(t *testing.T) {
assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
}
}
// 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 seed must be accepted at write, and the
// collection must match NOTHING — the composed query must never degrade
// to the bare scope head, which would swallow every episode in scope.
func TestEpisodeCollectionsEmptySeedMatchesNothing(t *testing.T) {
setup := setupTestServer(t)
regToken := setup.RegularToken
marker := fmt.Sprintf("emptyseed%d", time.Now().UnixNano()%100000)
seed := `{"scope":"episodes","mode":"all","children":[]}`
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test EmptySeed `+marker+`","collection_type":"smart","filter_tree":`+seed+`}`)
require.Equal(t, http.StatusCreated, code, "the empty seed must parse at write: %s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
t.Cleanup(func() {
podcastAuthed(t, setup, regToken, "DELETE", "/api/collections/"+coll.ID, "")
})
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), `"is_smart":true`,
"a seeded tree collection IS smart without any rules array")
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+coll.ID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
BookCount int `json:"book_count"`
Books []interface{} `json:"books"`
}
require.NoError(t, json.Unmarshal(body, &out))
assert.Equal(t, 0, out.BookCount,
"an empty-seed episodes collection matches nothing — a composer that degraded to the bare scope head would report the whole library here")
assert.Empty(t, out.Books)
}