feat(podcasts): unset listening order defaults to reversed (audiobook-first)
The per-feed listening order — the Order dropdown on the podcast page and player — resolved its UNSET state to "native": feed document order, which runs newest-first for classic RSS. This platform is audiobook- first, so native started every book at its LAST chapter (verified on live data: 7th Son position 1 = Bonus Episode #2, positions descending through the chapters). The unset fallback flips to "reverse" at every resolution site of that ONE setting — the podcast page's list + dropdown (podcastOrderForFeed), the player's feed chain (reader.go), and the download queue (orderOverrideFor) — so the dropdown shows reversed selected, the page renders in that order, and play/download follow. Stored overrides are untouched (native stays a valid savable choice); no ordering semantics changed; collections are untouched. Pins: the flipped native expectation is changelogged in contract_pins; new pins cover the sequence config carrying reverse, feed position 1 playing last, and the page dropdown rendering reverse selected for an unset feed.
This commit is contained in:
1 parent
65f257f377
commit
0e29eff7b7
5 files changed
+65
-12
No files matched your search
@@ -531,13 +531,31 @@ func TestPodcastPlayerOrderControl(t *testing.T) {
|
|||||||
// match the escaped form.
|
// match the escaped form.
|
||||||
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) }
|
||||||
|
|
||||||
t.Run("context_carries_feed_source", func(t *testing.T) {
|
t.Run("unset_default_is_reversed", func(t *testing.T) {
|
||||||
|
// 2026-11-01: the UNSET per-feed order resolves to "reverse"
|
||||||
|
// (audiobook-first — feed document order runs newest-first, so
|
||||||
|
// native started a book at its last chapter). The player's
|
||||||
|
// sequence config carries it; the podcast page's dropdown shows
|
||||||
|
// it selected; ep1 (feed position 1 of 2) plays LAST.
|
||||||
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
code, body := podcastAuthed(t, setup, setup.Token, "GET", ep1URL, "")
|
||||||
require.Equal(t, http.StatusOK, code)
|
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(`"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(`"feed_id":"`+feedID+`"`))
|
||||||
assert.Contains(t, string(body), esc(`"order":"native"`))
|
assert.Contains(t, string(body), esc(`"order":"reverse"`),
|
||||||
assert.Contains(t, string(body), esc(`"episode_index":1`))
|
"unset order defaults to reversed (audiobook-first)")
|
||||||
|
assert.Contains(t, string(body), esc(`"episode_index":2`),
|
||||||
|
"feed position 1 plays last under the reversed default")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("podcast_page_dropdown_shows_reversed_when_unset", func(t *testing.T) {
|
||||||
|
code, body := podcastAuthed(t, setup, setup.Token, "GET", "/podcasts/"+feedID, "")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
html := string(body)
|
||||||
|
assert.Contains(t, html, `id="podcast-order-select"`, "the feed page renders its Order dropdown")
|
||||||
|
assert.Contains(t, html, `<option value="reverse" selected`,
|
||||||
|
"the unset default renders reversed as the selected option")
|
||||||
|
assert.NotContains(t, html, `<option value="native" selected`,
|
||||||
|
"native is no longer the unset default")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) {
|
t.Run("order_dropdown_renders_feed_mode", func(t *testing.T) {
|
||||||
|
|||||||
@@ -649,3 +649,22 @@ episodes).
|
|||||||
- The quiet suggest fetch remains only as a fallback for trees the SSR
|
- The quiet suggest fetch remains only as a fallback for trees the SSR
|
||||||
never saw (client-side apply flows); embedded labels make it a no-op
|
never saw (client-side apply flows); embedded labels make it a no-op
|
||||||
on refresh.
|
on refresh.
|
||||||
|
|
||||||
|
## 2026-11-01 — unset podcast order defaults to reversed (audiobook-first)
|
||||||
|
|
||||||
|
The per-feed listening order (the D5 `podcast_order:<feedID>` KV shown
|
||||||
|
in the Order dropdown) resolved its UNSET state to "native" — feed
|
||||||
|
document order, which runs newest-first for classic RSS, so an
|
||||||
|
audiobook feed played its LAST chapter first. The unset default is now
|
||||||
|
`reverse` at every resolution site of that one setting: the podcast
|
||||||
|
page list + dropdown (`podcastOrderForFeed`), the player's feed chain
|
||||||
|
(reader.go), and the download worker (`orderOverrideFor`).
|
||||||
|
|
||||||
|
- Stored overrides are untouched: anyone who picked an order keeps it
|
||||||
|
(native remains a valid, savable choice).
|
||||||
|
- No ordering semantics changed — `OrderEpisodes` and the four-option
|
||||||
|
vocabulary are as they were; only the unset fallback flipped.
|
||||||
|
- Pinned: the player sequence config carries `"order":"reverse"` and
|
||||||
|
feed position 1 plays LAST for an unset feed; the podcast page's
|
||||||
|
dropdown renders `reverse` selected (TestPodcastPlayerOrderControl,
|
||||||
|
replacing the former native pin).
|
||||||
@@ -381,6 +381,13 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
|
|||||||
}
|
}
|
||||||
|
|
||||||
// podcastOrderForFeed reads the caller's order override (D5).
|
// podcastOrderForFeed reads the caller's order override (D5).
|
||||||
|
//
|
||||||
|
// The UNSET default is "reverse" (2026-11-01 user request): the platform
|
||||||
|
// is audiobook-first and feed document order runs newest-first, so
|
||||||
|
// native starts a book at its LAST chapter. This is the same per-feed
|
||||||
|
// setting the Order dropdown shows — page list, player chain, and
|
||||||
|
// download queue all resolve through it. Stored overrides are untouched:
|
||||||
|
// anyone who picked an order keeps exactly what they picked.
|
||||||
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
||||||
user := handlers.MustGetAuthenticatedUser(c)
|
user := handlers.MustGetAuthenticatedUser(c)
|
||||||
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
|
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
|
||||||
@@ -388,16 +395,16 @@ func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
|||||||
SettingKey: "podcast_order:" + feedID,
|
SettingKey: "podcast_order:" + feedID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "native"
|
return "reverse"
|
||||||
}
|
}
|
||||||
var v struct {
|
var v struct {
|
||||||
Order string `json:"order"`
|
Order string `json:"order"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(row, &v); err != nil {
|
if err := json.Unmarshal(row, &v); err != nil {
|
||||||
return "native"
|
return "reverse"
|
||||||
}
|
}
|
||||||
if !services.ValidPodcastEpisodeOrder(v.Order) {
|
if !services.ValidPodcastEpisodeOrder(v.Order) {
|
||||||
return "native"
|
return "reverse"
|
||||||
}
|
}
|
||||||
return v.Order
|
return v.Order
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,8 +187,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective order override (D5): reader_settings KV.
|
// Effective order override (D5): reader_settings KV. UNSET defaults
|
||||||
order := "native"
|
// to "reverse" (2026-11-01: audiobook-first — native document order
|
||||||
|
// starts a book at its last chapter); the same default the podcast
|
||||||
|
// page's Order dropdown shows. Stored overrides are untouched.
|
||||||
|
order := "reverse"
|
||||||
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
|||||||
@@ -317,7 +317,11 @@ func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
order := "native"
|
// THE order helper (§0 item 5a) — same semantics as the sequence,
|
||||||
|
// reader chain, and page list; no per-surface sort drift. UNSET
|
||||||
|
// defaults to "reverse" (2026-11-01, audiobook-first: chapter order
|
||||||
|
// downloads first so listening can start while the rest fetches).
|
||||||
|
order := "reverse"
|
||||||
if userID.Valid {
|
if userID.Valid {
|
||||||
order = w.orderOverrideFor(actx, userID, feedID)
|
order = w.orderOverrideFor(actx, userID, feedID)
|
||||||
}
|
}
|
||||||
@@ -344,19 +348,21 @@ func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// orderOverrideFor reads a user's podcast_order override via the
|
// orderOverrideFor reads a user's podcast_order override via the
|
||||||
// reader_settings KV (the D5 storage; native when absent/invalid).
|
// reader_settings KV (the D5 storage; "reverse" when absent/invalid —
|
||||||
|
// the 2026-11-01 audiobook-first default, same as every other
|
||||||
|
// resolution site of this setting).
|
||||||
func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string {
|
func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string {
|
||||||
row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||||
UserID: userID, SettingKey: "podcast_order:" + feedID.String(),
|
UserID: userID, SettingKey: "podcast_order:" + feedID.String(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "native"
|
return "reverse"
|
||||||
}
|
}
|
||||||
var v struct {
|
var v struct {
|
||||||
Order string `json:"order"`
|
Order string `json:"order"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) {
|
if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) {
|
||||||
return "native"
|
return "reverse"
|
||||||
}
|
}
|
||||||
return v.Order
|
return v.Order
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user