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:
1 parent
a90e7debfd
commit
adb93cba9e
11 files changed
+1636
-394
No files matched your search
@@ -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 ("); pins must match the escaped form.
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user