From a90e7debfd9b6e929cc8b1fb809de03bd751466f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 15:52:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20queue=20context=20source=20+?= =?UTF-8?q?=20player=20order=20control=20=E2=80=94=20item=202.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cmd/server/tests/podcast_test.go | 61 ++++++++ internal/router/reader.go | 5 + templates/audio_reader.templ | 32 +++++ templates/audio_reader_templ.go | 234 ++++++++++++++++++++----------- templates/types.go | 13 ++ web/src/audio.ts | 15 ++ 6 files changed, 279 insertions(+), 81 deletions(-) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 2357f035..098d967d 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -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), `