feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)

D11 lifts: podcast episodes (media_items rows) join collections, and
"Play collection as sequence" feeds the player from the collection's
order. No new tables — collection_items already carries ordered
membership (item 2.1 audit).

PICKER: the collection modal gains a Books/Episodes tab pair. The
episodes tab hits /api/media-items/search?scope=episodes — a dedicated
picker query (SearchEpisodesForPicker) whose visibility is entirely
RLS: the media_items subscription predicate drops unsubscribed feeds'
episodes, and the podcast_feeds join is governed the same way. The
books scope keeps its podcasts exclusion (plan §9) — this scope is the
only door in. Pending episodes are pickable (plan ahead; the detail
page shows them as not downloaded). Selection rides the existing
bookPicker store — ids never collide across scopes, and AddBooks works
unchanged on episode ids.

DETAIL: the All-Libraries membership view switches to
GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An
unsubscribed episode (or family-lens hidden book) survives as a
Hidden card rendered as an "Unavailable — remove entry" hint row
instead of silently vanishing; visible episodes render feed title,
download state, and a Play link. The library-scoped view keeps its
existing dashboard-query behavior (the switcher's contract is
untouched for books).

CHAIN: ?queue=collection:<id> on any reader URL makes
buildPodcastSequenceContext produce the COLLECTION context (§10 R2
same shape, source=collection): downloaded episodes in collection
order (positions are content — R1; no feed-order override), prev/next
URLs carrying the queue param so the chain survives hops, and
backToDetail landing on the collection page. Ownership gates the chain
— foreign/deleted ids fall back to the feed queue; the order control
stays hidden (2.2's source gate). The header's "Play as Sequence"
button starts from the first downloaded episode in list order.

Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped
picker + family-lens auditor case (subscribed episode in a hidden
library must not reach the picker), books-scope exclusion intact,
hint rows, chain carrying the queue param, start-from-first, foreign
fallback, and reorder driving the chain (the R4 primitive works on
episode ids unchanged).
This commit is contained in:
John O'Keefe committed 2026-10-30 16:18:29 -04:00
1 parent a90e7debfd
commit adb93cba9e
11 files changed
+1636 -394

No files matched your search

+205
View File
@@ -560,6 +560,211 @@ func TestPodcastPlayerOrderControl(t *testing.T) {
})
}
// ---- item 2.3: episode collections — picker scope, hint rows, the
// collection chain (queue=collection:<id>) ----
func TestPodcastEpisodeCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
lib, _ := createPodcastsLibrary(t, setup, "test EpiColl Podcasts")
marker := fmt.Sprintf("epicoll%d", time.Now().UnixNano()%100000)
// Feed A: the regular user SUBSCRIBES. Feed B: collected-but-UNSUBSCRIBED
// (the plan's hint-row case). Admin identity seeds both feeds' rows.
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "EpiColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "EpiColl Feed B " + marker,
})
require.NoError(t, err)
a0 := upsertTestEpisode(t, setup, feedA, marker+"-a0", "EpiColl A Zero", 1)
a1 := upsertTestEpisode(t, setup, feedA, marker+"-a1", "EpiColl A One", 2)
a3 := upsertTestEpisode(t, setup, feedA, marker+"-a3", "EpiColl A Three", 4)
a2 := upsertTestEpisode(t, setup, feedA, marker+"-a2", "EpiColl A Two Pending", 3)
b1 := upsertTestEpisode(t, setup, feedB, marker+"-b1", "EpiColl B One", 1)
adminScopeExec(t, setup, adminCtxValue(t, setup),
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
WHERE id IN ($1, $2, $3, $4) AND source = 'podcast_episode'`,
a0.ID, a1.ID, a3.ID, b1.ID) // a2 stays pending
subscribeRaw(t, setup, regUUID, feedA.ID)
// The collection (regular user's own) via the real API path.
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Up Next `+marker+`","description":"episode collection test"}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// Add episodes through the same endpoint the picker submits —
// book_ids is the key for ANY media id. Includes the unsubscribed
// b1: the insert must succeed; the DETAIL page decides visibility.
code, body = podcastAuthed(t, setup, regToken, "POST", "/api/collections/"+collID+"/books",
`{"book_ids":["`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a2.ID.Bytes).String()+`","`+uuid.UUID(b1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusNoContent, code, "%s", string(body))
// Pin the collection order explicitly (added_at ties are possible —
// same-transaction inserts share NOW(); positions make it
// deterministic). Order: A3, A0, A1 (+ pending A2, invisible B1).
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a3.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a1.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
// Sequence config rides a data-json ATTRIBUTE — templ escapes the
// quotes (&#34;); pins must match the escaped form.
esc := func(s string) string { return strings.ReplaceAll(s, `"`, "&#34;") }
t.Run("picker_scope_episodes_rls_scoped", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
html := buf.String()
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, html, "EpiColl A Zero", "subscribed feed's episodes appear")
assert.Contains(t, html, "EpiColl Feed A", "rows carry the feed title")
assert.Contains(t, html, "EpiColl A Two Pending", "pending episodes are pickable — the detail page shows them as not downloaded")
assert.Contains(t, html, "not downloaded", "pending picker rows carry the honest badge")
assert.NotContains(t, html, "EpiColl B One", "unsubscribed feed's episodes are RLS-dropped from the picker")
})
t.Run("picker_books_scope_still_excludes_podcasts", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl A Zero",
"the books scope keeps its podcasts exclusion (plan §9) — episodes enter ONLY through the episode scope")
})
t.Run("picker_respects_family_lens_hidden_libraries", func(t *testing.T) {
// Invariant-13 auditor case: a SUBSCRIBED episode in a
// family-HIDDEN library still drops — the episode predicate
// ANDs subscription with the family lens. (RLS auditor:
// membership-visible surfaces leak nothing from hidden libs.)
libH, _ := createPodcastsLibrary(t, setup, "test EpiColl Hidden")
feedH, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: libH.ID,
FeedUrl: "https://feed.invalid/" + marker + "-h.xml",
Title: "EpiColl Feed Hidden " + marker,
})
require.NoError(t, err)
upsertTestEpisode(t, setup, feedH, marker+"-h1", "EpiColl Hidden One", 1)
subscribeRaw(t, setup, regUUID, feedH.ID)
_, err = setup.DB.SetLibraryVisibility(seedCtx(), database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: regUUID, Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?show_checkbox=true&scope=episodes", nil)
req.Header.Set("HX-Request", "true")
req.Header.Set("Authorization", "Bearer "+regToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
assert.NotContains(t, buf.String(), "EpiColl Hidden One",
"LEAK: a subscribed episode in a family-hidden library must not reach the picker")
})
t.Run("detail_renders_episode_rows_and_unsubscribed_hint", func(t *testing.T) {
// The membership view (All Libraries cookie) shows the full
// collection including hint rows; the library-scoped view keeps
// its existing INNER-JOIN behavior (that's the switcher's
// contract, unchanged for 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, "EpiColl A One", "subscribed downloaded episode renders")
assert.Contains(t, html, "EpiColl Feed A", "episode rows carry the feed title")
assert.Contains(t, html, "Play", "downloaded episodes get the play affordance")
assert.Contains(t, html, "Not downloaded", "pending episode renders the honest state")
assert.Contains(t, html, "Unavailable", "collected-but-unsubscribed episode renders the hint row (LEFT JOIN)")
assert.Contains(t, html, "Remove entry", "hint rows stay removable")
assert.Contains(t, html, "Play as Sequence", "header sequence-start button renders when something can play")
})
t.Run("collection_chain_carries_queue_param", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
// The collection's pinned order is A3 → A0 → A1; a1 is LAST:
// prev = A0 (second-to-last), no next.
assert.Contains(t, html, esc(`"source":"collection"`))
assert.Contains(t, html, esc(`"episode_index":3`))
assert.Contains(t, html, esc(`"prev_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`),
"prev hop carries the queue param — the chain survives")
assert.NotContains(t, html, esc(`"next_url"`), "end of the collection queue")
assert.NotContains(t, html, `id="player-order-select"`,
"the order control is FEED-mode only (§10 R2) — collection order is content")
})
t.Run("start_button_plays_from_first_in_order", func(t *testing.T) {
// The header button starts from the FIRST playable episode in
// collection order (a3) — and that page chains forward to a0.
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a3.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
t.Run("foreign_collection_falls_back_to_feed_queue", func(t *testing.T) {
foreign := uuid.New().String()
code, body := podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+foreign, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"source":"feed"`), "not-owned collection id falls back to the feed queue")
assert.Contains(t, html, `id="player-order-select"`, "feed mode renders its order control")
})
t.Run("reorder_changes_the_chain", func(t *testing.T) {
// Reverse to A1 → A0 → A3: the same reader page now chains the
// other way — collection order IS the queue order (R1 content).
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/items/order",
`{"item_ids":["`+uuid.UUID(a1.ID.Bytes).String()+`","`+uuid.UUID(a0.ID.Bytes).String()+`","`+uuid.UUID(a3.ID.Bytes).String()+`"]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
code, body = podcastAuthed(t, setup, regToken, "GET",
"/readers/"+uuid.UUID(a1.ID.Bytes).String()+"?queue=collection:"+collID, "")
require.Equal(t, http.StatusOK, code)
html := string(body)
assert.Contains(t, html, esc(`"episode_index":1`))
assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(a0.ID.Bytes).String()+`?queue=collection:`+collID+`"`))
})
}
// ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) {