feat(podcasts): queue context source + player order control — item 2.2
Foundation rule R2 (§10): the queue context is the only queue, and every producer emits the same shape. PodcastSequenceContext gains source (feed|collection), feed_id, and order; the feed producer sets them now — the collection producer (2.3) slots into the same shape without the player ever special-casing beyond showing/hiding controls. The player header gains the FEED-mode order control next to the "Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a one-click reverse button. Both PUT the existing per-user feed order override — the SAME state the feed page writes, so flipping it in the player updates the feed page's dropdown too. The control renders only when source=feed: a collection queue's order is curated content (R1), never a listening preference. Behavior is hx-put + hx-swap="none" (response discarded) with a module-level htmx:afterRequest listener in audio.ts reloading the page — the server re-renders prev/next/index in the new order; templ decides what it looks like, JS only triggers the round trip (CSP-clean, no inline handlers). Pinned by TestPodcastPlayerOrderControl: the config JSON carries source/feed_id/order/episode_index; the control renders feed-mode; an order override re-renders with the dropdown reflecting it (data- json pins must match templ's " attribute escaping).
This commit is contained in:
1 parent
7bf0b1257d
commit
a90e7debfd
6 files changed
+279
-81
No files matched your search
@@ -499,6 +499,67 @@ func TestReaderSettingsCompositeKeyHeadroom(t *testing.T) {
|
||||
require.JSONEq(t, `{"order":"custom"}`, string(val))
|
||||
}
|
||||
|
||||
// ---- item 2.2: queue context source + the player's feed-mode order
|
||||
// control (§10 R2) ----
|
||||
|
||||
func TestPodcastPlayerOrderControl(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
lib, _ := createPodcastsLibrary(t, setup, "test PlayerOrder Podcasts")
|
||||
feed, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
|
||||
LibraryID: lib.ID,
|
||||
FeedUrl: "https://feed.invalid/player-order.xml",
|
||||
Title: "PlayerOrder Feed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
ep1 := upsertTestEpisode(t, setup, feed, "po-1", "PlayerOrder One", 1)
|
||||
ep2 := upsertTestEpisode(t, setup, feed, "po-2", "PlayerOrder Two", 2)
|
||||
|
||||
// download_state is download-owned (write-after-success guard) —
|
||||
// tests set it directly under the first-admin identity (raw-pool
|
||||
// execs on a detached context hit RLS and silently write 0 rows).
|
||||
adminScopeExec(t, setup, adminCtxValue(t, setup),
|
||||
`UPDATE media_items SET download_state = 'downloaded', file_size = 1000
|
||||
WHERE id IN ($1, $2) AND source = 'podcast_episode'`, ep1.ID, ep2.ID)
|
||||
subscribeRaw(t, setup, uuid.UUID(adminCtxValue(t, setup).Bytes), feed.ID)
|
||||
|
||||
feedID := uuid.UUID(feed.ID.Bytes).String()
|
||||
ep1URL := "/readers/" + uuid.UUID(ep1.ID.Bytes).String()
|
||||
|
||||
// The reader page embeds the sequence config in a data-json
|
||||
// ATTRIBUTE — templ HTML-escapes the quotes ("), so pins must
|
||||
// match the escaped form.
|
||||
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||
|
||||
t.Run("context_carries_feed_source", func(t *testing.T) {
|
||||
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), esc(`"source":"feed"`), "queue context must carry its owner (§10 R2)")
|
||||
assert.Contains(t, string(body), esc(`"feed_id":"`+feedID+`"`))
|
||||
assert.Contains(t, string(body), esc(`"order":"native"`))
|
||||
assert.Contains(t, string(body), esc(`"episode_index":1`))
|
||||
})
|
||||
|
||||
t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) {
|
||||
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), `id="player-order-select"`, "feed-mode queue renders the player order control")
|
||||
assert.Contains(t, string(body), `hx-put="/api/podcasts/feeds/`+feedID+`/order"`)
|
||||
assert.Contains(t, string(body), "Reverse episode order", "quick reverse button present")
|
||||
})
|
||||
|
||||
t.Run("order_override_hides_dropdown_after_switch", func(t *testing.T) {
|
||||
// Switch the order the way the player does (the same endpoint;
|
||||
// podcastAuthed sends JSON and the handler binds both shapes),
|
||||
// then re-render: the dropdown must reflect the new value.
|
||||
code, body := podcastAuthed(t, setup, setup.Token, "PUT", "/api/podcasts/feeds/"+feedID+"/order", `{"order":"reverse"}`)
|
||||
require.Equal(t, http.StatusOK, code, "%s", string(body))
|
||||
code, body = podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.Contains(t, string(body), esc(`"order":"reverse"`))
|
||||
assert.Contains(t, string(body), `<option value="reverse" selected`, "dropdown reflects the override")
|
||||
})
|
||||
}
|
||||
|
||||
// ---- download worker: state machine, idempotency, file layout ----
|
||||
|
||||
func TestPodcastDownloadWorker(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user