diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go
index d0ed520d..4531db8b 100644
--- a/cmd/server/tests/podcast_test.go
+++ b/cmd/server/tests/podcast_test.go
@@ -11,12 +11,12 @@ package main
import (
"bytes"
- "net/url"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -105,18 +105,18 @@ func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (d
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
t.Helper()
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
- LibraryID: feed.LibraryID,
- Title: title,
- Authors: []string{},
- AuthorsSearch: []string{},
- FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
- Tags: []string{},
- SourceFeedID: feed.ID,
- EpisodeGuid: pgtype.Text{String: guid, Valid: true},
- FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
- EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
- Hosts: []string{},
- Guests: []string{},
+ LibraryID: feed.LibraryID,
+ Title: title,
+ Authors: []string{},
+ AuthorsSearch: []string{},
+ FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
+ Tags: []string{},
+ SourceFeedID: feed.ID,
+ EpisodeGuid: pgtype.Text{String: guid, Valid: true},
+ FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
+ EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
+ Hosts: []string{},
+ Guests: []string{},
})
require.NoError(t, err, "seed episode %q", title)
return ep
@@ -664,8 +664,6 @@ func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, s
require.NoError(t, err)
}
-
-
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
func TestPodcastOrderFormEncoding(t *testing.T) {
@@ -695,10 +693,74 @@ func TestPodcastOrderFormEncoding(t *testing.T) {
assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind")
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
+ assert.Equal(t, http.StatusOK, formPut("order=reverse"), "reverse binds too (§0 item 3)")
assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)")
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
}
+// ---- §0 item 3: reverse = feed position DESC (no pub dates involved) ----
+
+func TestPodcastReverseOrder(t *testing.T) {
+ setup := setupTestServer(t)
+ adminCtx := adminSeedCtx(t, setup)
+ adminID := adminCtxValue(t, setup)
+ lib, _ := createPodcastsLibrary(t, setup, "test ReverseOrder Podcasts")
+ feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{
+ LibraryID: lib.ID,
+ FeedUrl: "https://feed.invalid/reverse-order.xml",
+ Title: "Reverse Order Feed",
+ })
+ require.NoError(t, err)
+ feedIDStr := uuid.UUID(feed.ID.Bytes).String()
+
+ // Episodes with MESSY dates (the Scribl condition): all equal.
+ // reverse must be driven by feed position alone — position 3 first.
+ epA := upsertTestEpisode(t, setup, feed, "rr-1", "Reverse Alpha", 1)
+ epB := upsertTestEpisode(t, setup, feed, "rr-2", "Reverse Beta", 2)
+ epC := upsertTestEpisode(t, setup, feed, "rr-3", "Reverse Gamma", 3)
+ adminScopeExec(t, setup, adminID, `
+ UPDATE media_items SET
+ download_state = 'downloaded',
+ file_size = 1000,
+ date_published = '2020-01-01T00:00:00Z'::timestamptz
+ WHERE id IN ($1, $2, $3) AND source = 'podcast_episode'`, epA.ID, epB.ID, epC.ID)
+ subscribeRaw(t, setup, uuid.UUID(adminID.Bytes), feed.ID)
+
+ // Set the override to reverse (the wire the "Reverse feed order"
+ // button uses).
+ formPut := func(body string) int {
+ req, _ := http.NewRequest(http.MethodPut,
+ setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order",
+ strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Authorization", "Bearer "+setup.Token)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ return resp.StatusCode
+ }
+ require.Equal(t, http.StatusOK, formPut("order=reverse"))
+
+ // The SEQUENCE endpoint must return position 3, 2, 1 — driven by
+ // feed position DESC, dates being all equal (the Scribl guard).
+ _, body := podcastAuthed(t, setup, setup.Token, "GET",
+ "/api/podcasts/feeds/"+feedIDStr+"/sequence", "")
+ var seq struct {
+ Order string `json:"order"`
+ Episodes []struct {
+ Title string `json:"title"`
+ FeedPosition int `json:"feed_position"`
+ } `json:"episodes"`
+ }
+ require.NoError(t, json.Unmarshal(body, &seq))
+ assert.Equal(t, "reverse", seq.Order, "the endpoint echoes the effective order")
+ require.Len(t, seq.Episodes, 3)
+ assert.Equal(t, 3, seq.Episodes[0].FeedPosition, "PIN: reverse = position DESC, date-agnostic")
+ assert.Equal(t, 1, seq.Episodes[2].FeedPosition)
+ assert.Equal(t, "Reverse Gamma", seq.Episodes[0].Title,
+ "position 3 first — a messy-date feed reorders by position, not dates")
+}
+
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
func TestPodcastPageSearchEpisodes(t *testing.T) {
diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go
index a169bf5d..2090c7f7 100644
--- a/internal/handlers/podcast.go
+++ b/internal/handlers/podcast.go
@@ -9,7 +9,6 @@ package handlers
import (
"context"
"encoding/json"
- "fmt"
"log"
"sort"
@@ -39,8 +38,11 @@ type PodcastHandler struct {
// EpisodeListRenderer renders the post-action episode list for an htmx
// request. includePolicySelect additionally out-of-band-swaps the
// auto-download select (Download All flips the caller's policy — the
-// dropdown must reflect it immediately, issue #3).
-type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error
+// dropdown must reflect it immediately, issue #3);
+// includeOrderSelect likewise rides along for the order endpoint (the
+// "Reverse feed order" button flips to reverse — the dropdown must
+// mirror the server state, §0 item 3).
+type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
@@ -49,9 +51,9 @@ func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
// hxEpisodeList answers an htmx action with the refreshed partial when
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
-func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect bool) error {
+func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
if h.episodeListRenderer != nil {
- return h.episodeListRenderer(c, feedID, includePolicySelect)
+ return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
}
return hxAccepted(c, "")
}
@@ -328,7 +330,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
if isHXRequest(c) {
// Policy changes feed the auto-download union on the next poll.
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
- return h.hxEpisodeList(c, feedID.String(), true)
+ return h.hxEpisodeList(c, feedID.String(), true, false)
}
return c.JSON(http.StatusOK, updated)
}
@@ -367,7 +369,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
- return h.hxEpisodeList(c, feedID.String(), false)
+ return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
}
@@ -418,7 +420,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
// Download All flips the caller's policy to all (issue #3)
// — the oob select keeps the dropdown honest.
h.flipSubscriptionToAll(ctx, user.ID, feedID)
- return h.hxEpisodeList(c, feedID.String(), true)
+ return h.hxEpisodeList(c, feedID.String(), true, false)
}
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
}
@@ -430,7 +432,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
return podcastErrorResponse(c, err)
}
if isHXRequest(c) {
- return h.hxEpisodeList(c, feedID.String(), false)
+ return h.hxEpisodeList(c, feedID.String(), false, false)
}
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
}
@@ -503,9 +505,10 @@ func podcastOrderSettingKey(feedID string) string {
return "podcast_order:" + feedID
}
-// podcastOrderValid: native (feed position) | oldest | newest.
+// podcastOrderValid native (feed position) | reverse (position DESC,
+// no dates) | oldest | newest.
func podcastOrderValid(o string) bool {
- return o == "native" || o == "oldest" || o == "newest"
+ return services.ValidPodcastEpisodeOrder(o)
}
// GetFeedOrder reads the caller's effective order for a feed
@@ -538,7 +541,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
}
var req struct {
- Order string `json:"order" form:"order" validate:"required,oneof=native oldest newest"`
+ Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
@@ -564,7 +567,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
}
}
if isHXRequest(c) {
- return h.hxEpisodeList(c, feedID.String(), false)
+ return h.hxEpisodeList(c, feedID.String(), false, true)
}
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
}
@@ -590,19 +593,11 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
for i := range eps {
views = append(views, episodeView(&eps[i]))
}
- sortSlice(views, func(a, b PodcastEpisodeView) bool {
- switch order {
- case "oldest":
- return episodeSortKey(a) < episodeSortKey(b)
- case "newest":
- return episodeSortKey(a) > episodeSortKey(b)
- default: // native: feed position (fetch-maintained)
- if a.FeedPosition != b.FeedPosition {
- return a.FeedPosition < b.FeedPosition
- }
- return a.Date < b.Date
- }
- })
+ // Effective listening order (order consolidation §0 item 5a).
+ services.OrderEpisodes(views, order,
+ func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
+ func(v PodcastEpisodeView) string { return v.Date },
+ )
sequence := make([]PodcastEpisodeView, 0, len(views))
gaps := make([]PodcastEpisodeView, 0)
@@ -621,12 +616,6 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
})
}
-// episodeSortKey: chronological key for oldest/newest (date, then
-// native position as the same-day tiebreaker).
-func episodeSortKey(v PodcastEpisodeView) string {
- return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
-}
-
// sortSlice: sort.Slice with a controller-friendly wrapper.
func sortSlice[T any](s []T, less func(a, b T) bool) {
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
diff --git a/internal/router/podcast.go b/internal/router/podcast.go
index 792bd9bf..ce975550 100644
--- a/internal/router/podcast.go
+++ b/internal/router/podcast.go
@@ -20,7 +20,7 @@ func registerPodcastRoutes(cfg *Config) {
// refreshed partial directly — no event indirection to miss. The
// view assembly lives here (templates) while the action logic lives
// in the handler; this closure is the sanctioned seam.
- cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect bool) error {
+ cfg.PodcastHandler.SetEpisodeListRenderer(func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return err
@@ -48,6 +48,14 @@ func registerPodcastRoutes(cfg *Config) {
return err
}
}
+ if includeOrderSelect {
+ // §0 item 3: out-of-band swap of the order select — the
+ // "Reverse feed order" button flips to reverse; the
+ // dropdown mirrors the server state immediately.
+ if err := templates.PodcastOrderSelectOOB(data.Feed.ID, data.Order).Render(c.Request().Context(), &buf); err != nil {
+ return err
+ }
+ }
return c.HTML(http.StatusOK, buf.String())
})
diff --git a/internal/router/podcast_page.go b/internal/router/podcast_page.go
index 7ac8ed61..379ed553 100644
--- a/internal/router/podcast_page.go
+++ b/internal/router/podcast_page.go
@@ -9,6 +9,7 @@ package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
+ "bookhoard/internal/services"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
@@ -105,9 +106,9 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
}
for _, lib := range libs {
data.Libraries = append(data.Libraries, templates.LibraryData{
- ID: uuid.UUID(lib.ID.Bytes).String(),
- Name: lib.Name,
- TypeName: "podcasts",
+ ID: uuid.UUID(lib.ID.Bytes).String(),
+ Name: lib.Name,
+ TypeName: "podcasts",
})
}
@@ -132,30 +133,30 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
- ID: uuid.UUID(row.ID.Bytes).String(),
- LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
- Title: row.Title,
- DisplayName: row.DisplayName.String,
- Description: firstLine(row.Description.String, 220),
- Host: row.Host.String,
- Publisher: row.Publisher.String,
- Language: row.Language.String,
- Tags: row.Tags,
- Explicit: row.Explicit,
- ArtworkPath: row.ArtworkPath.String,
- FeedType: row.FeedType.String,
- Medium: row.Medium.String,
- IsComplete: row.IsComplete,
- FeedURL: row.FeedUrl,
- LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
- SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
- EpisodeTotal: int(total),
- DownloadedN: int(downloaded),
- UndownloadedN: int(total) - int(downloaded),
- NewEpisodes: total-downloaded > 0,
- AutoDownload: row.AutoDownload,
+ ID: uuid.UUID(row.ID.Bytes).String(),
+ LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
+ Title: row.Title,
+ DisplayName: row.DisplayName.String,
+ Description: firstLine(row.Description.String, 220),
+ Host: row.Host.String,
+ Publisher: row.Publisher.String,
+ Language: row.Language.String,
+ Tags: row.Tags,
+ Explicit: row.Explicit,
+ ArtworkPath: row.ArtworkPath.String,
+ FeedType: row.FeedType.String,
+ Medium: row.Medium.String,
+ IsComplete: row.IsComplete,
+ FeedURL: row.FeedUrl,
+ LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
+ SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
+ EpisodeTotal: int(total),
+ DownloadedN: int(downloaded),
+ UndownloadedN: int(total) - int(downloaded),
+ NewEpisodes: total-downloaded > 0,
+ AutoDownload: row.AutoDownload,
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
- Subscribed: true,
+ Subscribed: true,
})
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
card := &data.Feeds[len(data.Feeds)-1]
@@ -253,7 +254,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
UserID: actor.ID, SourceFeedID: feedID,
})
progressByItem := make(map[string]struct {
- Pct float64
+ Pct float64
LastRead pgtype.Timestamptz
}, len(progressRows))
for _, p := range progressRows {
@@ -268,29 +269,29 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
subscriberCount, _ := cfg.Queries.CountPodcastFeedSubscribers(ctx, feedID)
card := templates.PodcastFeedCard{
- ID: uuid.UUID(feed.ID.Bytes).String(),
- LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
- Title: feed.Title,
- DisplayName: sub.DisplayName.String,
- Description: feed.Description.String,
- Host: feed.Host.String,
- Publisher: feed.Publisher.String,
- Language: feed.Language.String,
- Tags: feed.Tags,
- Explicit: feed.Explicit,
- ArtworkPath: feed.ArtworkPath.String,
- FeedType: feed.FeedType.String,
- Medium: feed.Medium.String,
- IsComplete: feed.IsComplete,
- FeedURL: feed.FeedUrl,
- LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
- EpisodeTotal: int(total),
- DownloadedN: int(downloaded),
- UndownloadedN: int(total) - int(downloaded),
- NewEpisodes: total-downloaded > 0,
- AutoDownload: sub.AutoDownload,
+ ID: uuid.UUID(feed.ID.Bytes).String(),
+ LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
+ Title: feed.Title,
+ DisplayName: sub.DisplayName.String,
+ Description: feed.Description.String,
+ Host: feed.Host.String,
+ Publisher: feed.Publisher.String,
+ Language: feed.Language.String,
+ Tags: feed.Tags,
+ Explicit: feed.Explicit,
+ ArtworkPath: feed.ArtworkPath.String,
+ FeedType: feed.FeedType.String,
+ Medium: feed.Medium.String,
+ IsComplete: feed.IsComplete,
+ FeedURL: feed.FeedUrl,
+ LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
+ EpisodeTotal: int(total),
+ DownloadedN: int(downloaded),
+ UndownloadedN: int(total) - int(downloaded),
+ NewEpisodes: total-downloaded > 0,
+ AutoDownload: sub.AutoDownload,
AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
- Subscribed: sub.ID.Valid,
+ Subscribed: sub.ID.Valid,
}
rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
@@ -316,46 +317,45 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
rowID := uuid.UUID(ep.ID.Bytes).String()
p := progressByItem[rowID]
dateDisplay := ""
+ dateSort := ""
if ep.DatePublished.Valid {
+ dateSort = ep.DatePublished.Time.Format("2006-01-02")
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
}
rows = append(rows, templates.PodcastEpisodeRow{
- ID: uuid.UUID(ep.ID.Bytes).String(),
- FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
- Title: ep.Title,
- Description: firstLine(ep.Description.String, 200),
- Date: dateDisplay,
- Position: int(ep.FeedPosition.Int32),
- Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
- Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
- PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
- ListenPct: p.Pct,
- Downloaded: ep.DownloadState == "downloaded",
+ ID: uuid.UUID(ep.ID.Bytes).String(),
+ FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
+ Title: ep.Title,
+ Description: firstLine(ep.Description.String, 200),
+ Date: dateDisplay,
+ SortDate: dateSort,
+ Position: int(ep.FeedPosition.Int32),
+ Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
+ Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
+ PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
+ ListenPct: p.Pct,
+ Downloaded: ep.DownloadState == "downloaded",
DownloadState: ep.DownloadState,
DownloadError: ep.DownloadError.String,
})
}
// Order override reshapes the LIST too (the sequence endpoint is the
// strict downloaded-only view; the list shows everything in the
- // chosen order so pending episodes stay visible).
- if order == "oldest" || order == "newest" {
- sort.SliceStable(rows, func(a, b int) bool {
- ka := rows[a].Date + itoaPad(rows[a].Position)
- kb := rows[b].Date + itoaPad(rows[b].Position)
- if order == "newest" {
- return ka > kb
- }
- return ka < kb
- })
- }
+ // chosen order so pending episodes stay visible). Consolidated sort
+ // (§0 item 5a) — SortDate is the lexical pub-date key so the
+ // tiebreak is chronological even in display-tz date strings.
+ services.OrderEpisodes(rows, order,
+ func(r templates.PodcastEpisodeRow) int32 { return int32(r.Position) },
+ func(r templates.PodcastEpisodeRow) string { return r.SortDate },
+ )
detail := &templates.PodcastDetailData{
- Feed: card,
- Episodes: rows,
- GapCount: gaps,
- Order: order,
- IsAdmin: actor.Role == "admin",
- IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
+ Feed: card,
+ Episodes: rows,
+ GapCount: gaps,
+ Order: order,
+ IsAdmin: actor.Role == "admin",
+ IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
SubscriberCount: int(subscriberCount),
}
@@ -396,11 +396,10 @@ func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
if err := json.Unmarshal(row, &v); err != nil {
return "native"
}
- switch v.Order {
- case "oldest", "newest":
- return v.Order
+ if !services.ValidPodcastEpisodeOrder(v.Order) {
+ return "native"
}
- return "native"
+ return v.Order
}
// facetValuesFromCards derives the filter bar values.
@@ -455,7 +454,6 @@ func itoaPad(n int) string {
return fmt.Sprintf("%06d", n)
}
-
// parsePodcastUUID parses an id path param (handlers.parseUUID is
// package-private).
func parsePodcastUUID(s string) (pgtype.UUID, error) {
diff --git a/internal/router/reader.go b/internal/router/reader.go
index a8057025..384899f9 100644
--- a/internal/router/reader.go
+++ b/internal/router/reader.go
@@ -9,9 +9,7 @@ import (
"bookhoard/templates"
"bytes"
"encoding/json"
- "fmt"
"net/http"
- "sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -186,7 +184,7 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
var v struct {
Order string `json:"order"`
}
- if json.Unmarshal(row, &v) == nil && (v.Order == "oldest" || v.Order == "newest") {
+ if json.Unmarshal(row, &v) == nil && services.ValidPodcastEpisodeOrder(v.Order) {
order = v.Order
}
}
@@ -215,17 +213,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
pos: ep.FeedPosition.Int32,
})
}
- sort.SliceStable(ordered, func(a, b int) bool {
- ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
- kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
- if order == "newest" {
- return ka > kb
- }
- if order == "oldest" {
- return ka < kb
- }
- return ordered[a].pos < ordered[b].pos
- })
+ // Effective listening order (order consolidation §0 item 5a).
+ services.OrderEpisodes(ordered, order,
+ func(e seqEp) int32 { return e.pos },
+ func(e seqEp) string { return e.date },
+ )
idx := -1
for i, ep := range ordered {
diff --git a/internal/services/podcast_order.go b/internal/services/podcast_order.go
new file mode 100644
index 00000000..b7c3a406
--- /dev/null
+++ b/internal/services/podcast_order.go
@@ -0,0 +1,60 @@
+package services
+
+import (
+ "fmt"
+ "sort"
+)
+
+// Podcast episode ordering — THE single source of order truth (§0 item
+// 3 + item 5a, 2026-10-30). Every surface (sequence JSON, reader chain
+// context, SSR page list, download queueing) routes through
+// OrderEpisodes; per-surface sort blocks were the drift risk (three
+// near-identical comparators existed by hand).
+
+// PodcastEpisodeOrder values:
+// - native: feed position ASC, date ASC tiebreak (fetch-maintained order)
+// - reverse: feed position DESC, date DESC tiebreak ("Reverse feed
+// order" — no pub dates involved, so Scribl's messy/all-equal
+// migrated podiobooks dates can't break it)
+// - oldest / newest: chronological by pub date string (2006-01-02 or
+// anything lexical-comparable), position as same-date tiebreak
+func ValidPodcastEpisodeOrder(o string) bool {
+ return o == "native" || o == "reverse" || o == "oldest" || o == "newest"
+}
+
+// OrderEpisodes sorts eps in place into the given order. Accessor
+// functions supply each episode's feed position and pub date key
+// (LEN lexical-comparable, e.g. RFC3339 or 2006-01-02).
+func OrderEpisodes[T any](eps []T, order string, pos func(T) int32, date func(T) string) {
+ switch order {
+ case "reverse":
+ sort.SliceStable(eps, func(i, j int) bool {
+ if pos(eps[i]) != pos(eps[j]) {
+ return pos(eps[i]) > pos(eps[j])
+ }
+ return date(eps[i]) > date(eps[j])
+ })
+ case "oldest", "newest":
+ sort.SliceStable(eps, func(i, j int) bool {
+ ka := date(eps[i]) + episodePosKey(pos(eps[i]))
+ kb := date(eps[j]) + episodePosKey(pos(eps[j]))
+ if order == "newest" {
+ return ka > kb
+ }
+ return ka < kb
+ })
+ default: // native
+ sort.SliceStable(eps, func(i, j int) bool {
+ if pos(eps[i]) != pos(eps[j]) {
+ return pos(eps[i]) < pos(eps[j])
+ }
+ return date(eps[i]) < date(eps[j])
+ })
+ }
+}
+
+// episodePosKey: zero-padded position so lexical date keys tiebreak
+// numerically.
+func episodePosKey(pos int32) string {
+ return fmt.Sprintf("%06d", pos)
+}
diff --git a/templates/podcast_detail.templ b/templates/podcast_detail.templ
index 2da0f228..c5a332a7 100644
--- a/templates/podcast_detail.templ
+++ b/templates/podcast_detail.templ
@@ -116,21 +116,24 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
hx-swap="outerHTML"
>
-
-
+
+
+
-
+
if data.IsSerial && data.Order == "native" {
}
@@ -382,6 +385,27 @@ templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
}
+// PodcastOrderSelectOOB is the out-of-band variant of the order
+// dropdown: same element id + hx-swap-oob so htmx replaces the
+// action-row select in place (§0 item 3 — the "Reverse feed order"
+// button flips the order server-side; the dropdown must show it).
+templ PodcastOrderSelectOOB(feedID string, order string) {
+
+}
+
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
// id + hx-swap-oob so htmx replaces the action-row select in place.
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
diff --git a/templates/podcast_detail_templ.go b/templates/podcast_detail_templ.go
index 75ead69e..f50dfe4a 100644
--- a/templates/podcast_detail_templ.go
+++ b/templates/podcast_detail_templ.go
@@ -370,25 +370,25 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">Feed order ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Oldest first (pub date) ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if data.IsSerial && data.Order == "native" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-vals='{\"order\": \"reverse\"}' hx-target=\"#episode-list\" hx-swap=\"outerHTML\" title=\"Serial feed: publishers list newest first — reverse the feed order to start at chapter 1\">Reverse feed order")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -428,85 +441,85 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component {
return templ_7745c5c3_Err
}
if data.Feed.AutoDownload == "newest" {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- if data.IsAdmin && data.SubscriberCount <= 1 {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " ")
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- } else if data.IsAdmin {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"input w-20 py-1.5\" aria-label=\"Newest N episodes\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- } else {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- }
- if data.IsAdmin {
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed") + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 200, Col: 160} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 203, Col: 160} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
Playback skips them and keeps going — use Download All to fill the gaps.
Playback skips them and keeps going — use Download All to fill the gaps.