diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index ad2a168c..5de835ba 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -14,6 +14,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -1021,6 +1022,11 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) { assert.Contains(t, h, "TreeColl New") assert.Contains(t, h, "TreeColl New Two") assert.NotContains(t, h, "TreeColl Old", "published 30d ago fails within_days") + // The list IS the play order: native feed order puts New + // (position 1) before New Two (position 5). Quote-bounded so + // "TreeColl New" never matches inside "TreeColl New Two". + assert.Less(t, strings.Index(h, `"TreeColl New"`), strings.Index(h, `"TreeColl New Two"`), + "native list order = feed positions") }) t.Run("chain_follows_the_tree", func(t *testing.T) { @@ -1073,6 +1079,24 @@ func TestEpisodeCollectionsFilterTree(t *testing.T) { assert.Contains(t, string(body), `"chain_order":"reverse"`, "view_settings merge keeps chain_order across whole-map writes") + // THE PAGE follows the flip (2026-11-01 user report: the select + // changed nothing on the page): reverse puts feed position 5 + // (New Two) BEFORE position 1 (New) on the rendered cards — + // the visible order is what the sequence will play, and Play + // launches from the first card. + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil) + req.Header.Set("Authorization", "Bearer "+regToken) + req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"}) + page, perr := http.DefaultClient.Do(req) + require.NoError(t, perr) + pageBody, _ := io.ReadAll(page.Body) + _ = page.Body.Close() + require.Equal(t, http.StatusOK, page.StatusCode) + ph := string(pageBody) + require.Contains(t, ph, "TreeColl New", "the page renders the smart cards at all") + assert.Less(t, strings.Index(ph, "TreeColl New Two"), strings.Index(ph, `"TreeColl New"`), + "reverse re-orders the page cards — the visible order is the play order") + // aNew is feed position 1: FIRST under native, LAST under reverse. code, body = podcastAuthed(t, setup, regToken, "GET", "/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "") diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 301c13a8..cf046d86 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -316,6 +316,13 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, if evalErr != nil { return nil, isSmart, "books_load_error" } + // The visible order IS the play order: the curated listening + // order (view_settings.chain_order, §10 R2) re-orders the + // episode cards exactly as the sequence producer will play + // them, so the select's effect is visible and Play launches + // from the card the user sees first (2026-11-01 user report: + // the select changed nothing on the page). + services.OrderEpisodeMedia(matches, handlers.CollectionChainOrder(collection.ViewSettings)) books = handlers.BookCardsFromMediaItems(matches, libTypes) // Episode-awareness for smart matches (item 2.5): episode cards // carry the play affordance (chain via ?queue=collection:). diff --git a/internal/router/reader.go b/internal/router/reader.go index a8534969..38e038ad 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -295,6 +295,25 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database if err != nil || len(eps) == 0 { return nil } + // The curated listening order applies to static episode playlists + // too (§10 R2): native/"" keeps the manual position order (the SQL + // order above); reverse flips it; oldest/newest re-key on + // publication date with feed position as the tiebreak. + switch order := handlers.CollectionChainOrder(coll.ViewSettings); order { + case "reverse": + for i, j := 0, len(eps)-1; i < j; i, j = i+1, j-1 { + eps[i], eps[j] = eps[j], eps[i] + } + case "oldest", "newest": + services.OrderEpisodes(eps, order, + func(e database.GetCollectionEpisodesOrderedRow) int32 { return e.FeedPosition.Int32 }, + func(e database.GetCollectionEpisodesOrderedRow) string { + if e.DatePublished.Valid { + return e.DatePublished.Time.Format("2006-01-02") + } + return "" + }) + } currentID := uuid.UUID(item.ID.Bytes).String() idx := -1 diff --git a/internal/services/podcast_order.go b/internal/services/podcast_order.go index b7c3a406..170278db 100644 --- a/internal/services/podcast_order.go +++ b/internal/services/podcast_order.go @@ -1,6 +1,7 @@ package services import ( + "bookhoard/internal/database" "fmt" "sort" ) @@ -53,6 +54,32 @@ func OrderEpisodes[T any](eps []T, order string, pos func(T) int32, date func(T) } } +// OrderEpisodeMedia applies the curated listening order to a media +// match list — the SMART collection page renders exactly what the +// sequence will play (2026-11-01 user report: flipping the order select +// changed nothing on the page, and Play launches from the first card). +// Non-episode match lists are left untouched (title order stays). +func OrderEpisodeMedia(items []database.MediaItems, order string) { + anyEpisode := false + for _, m := range items { + if m.Source == "podcast_episode" { + anyEpisode = true + break + } + } + if !anyEpisode { + return + } + OrderEpisodes(items, order, + func(m database.MediaItems) int32 { return m.FeedPosition.Int32 }, + func(m database.MediaItems) string { + if m.DatePublished.Valid { + return m.DatePublished.Time.Format("2006-01-02") + } + return "" + }) +} + // episodePosKey: zero-padded position so lexical date keys tiebreak // numerically. func episodePosKey(pos int32) string {