feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's migrated podiobooks feeds (messy/all-equal dates). New order value 'reverse' = feed position DESC with a date-DESC tiebreak — dates are not involved, so nothing can break. - services.OrderEpisodes: THE single order source of truth (§0 items 3+5a). The four surfaces (sequence JSON, reader chain context, SSR page list) previously carried three near-identical handwritten comparators — all now route through one generic helper. The SSR page list gains a SortDate key (lexical 2006-01-02) so the pub-date tiebreak is actually chronological; the old comparator compared 'Jan 2, 2006' display strings alphabetically (latent drift bug). - Dropdown becomes four options: Feed order / Feed order (reversed) / Oldest first (pub date) / Newest first (pub date). - Serial suggestion button renamed 'Reverse feed order' (sets reverse, not oldest) and its response out-of-band-swaps the order select (PodcastOrderSelectOOB — same mechanism as Download All's policy select), so the dropdown mirrors the server state immediately. - Validation: oneof=native reverse oldest newest everywhere. - Tests: reverse binds via the form wire; messy-dates sequence returns 3,2,1; existing reorder-safety stays green.
This commit is contained in:
1 parent
7c7c08748e
commit
e3675536aa
9 files changed
+608
-363
No files matched your search
@@ -11,12 +11,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/url"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -664,8 +664,6 @@ func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, s
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
|
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
|
||||||
|
|
||||||
func TestPodcastOrderFormEncoding(t *testing.T) {
|
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=oldest"), "form-encoded order must bind")
|
||||||
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
|
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(""), "missing order stays rejected (required)")
|
||||||
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
|
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 ----
|
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
|
||||||
|
|
||||||
func TestPodcastPageSearchEpisodes(t *testing.T) {
|
func TestPodcastPageSearchEpisodes(t *testing.T) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
@@ -39,8 +38,11 @@ type PodcastHandler struct {
|
|||||||
// EpisodeListRenderer renders the post-action episode list for an htmx
|
// EpisodeListRenderer renders the post-action episode list for an htmx
|
||||||
// request. includePolicySelect additionally out-of-band-swaps the
|
// request. includePolicySelect additionally out-of-band-swaps the
|
||||||
// auto-download select (Download All flips the caller's policy — the
|
// auto-download select (Download All flips the caller's policy — the
|
||||||
// dropdown must reflect it immediately, issue #3).
|
// dropdown must reflect it immediately, issue #3);
|
||||||
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error
|
// 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).
|
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
|
||||||
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
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
|
// hxEpisodeList answers an htmx action with the refreshed partial when
|
||||||
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
|
// 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 {
|
if h.episodeListRenderer != nil {
|
||||||
return h.episodeListRenderer(c, feedID, includePolicySelect)
|
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
|
||||||
}
|
}
|
||||||
return hxAccepted(c, "")
|
return hxAccepted(c, "")
|
||||||
}
|
}
|
||||||
@@ -328,7 +330,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
|
|||||||
if isHXRequest(c) {
|
if isHXRequest(c) {
|
||||||
// Policy changes feed the auto-download union on the next poll.
|
// Policy changes feed the auto-download union on the next poll.
|
||||||
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
_, _ = 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)
|
return c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
@@ -367,7 +369,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
|||||||
return podcastErrorResponse(c, err)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
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"})
|
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)
|
// Download All flips the caller's policy to all (issue #3)
|
||||||
// — the oob select keeps the dropdown honest.
|
// — the oob select keeps the dropdown honest.
|
||||||
h.flipSubscriptionToAll(ctx, user.ID, feedID)
|
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})
|
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)
|
return podcastErrorResponse(c, err)
|
||||||
}
|
}
|
||||||
if isHXRequest(c) {
|
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"})
|
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
||||||
}
|
}
|
||||||
@@ -503,9 +505,10 @@ func podcastOrderSettingKey(feedID string) string {
|
|||||||
return "podcast_order:" + feedID
|
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 {
|
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
|
// 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"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||||
}
|
}
|
||||||
var req struct {
|
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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
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) {
|
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})
|
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 {
|
for i := range eps {
|
||||||
views = append(views, episodeView(&eps[i]))
|
views = append(views, episodeView(&eps[i]))
|
||||||
}
|
}
|
||||||
sortSlice(views, func(a, b PodcastEpisodeView) bool {
|
// Effective listening order (order consolidation §0 item 5a).
|
||||||
switch order {
|
services.OrderEpisodes(views, order,
|
||||||
case "oldest":
|
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
|
||||||
return episodeSortKey(a) < episodeSortKey(b)
|
func(v PodcastEpisodeView) string { return v.Date },
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sequence := make([]PodcastEpisodeView, 0, len(views))
|
sequence := make([]PodcastEpisodeView, 0, len(views))
|
||||||
gaps := make([]PodcastEpisodeView, 0)
|
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.
|
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
||||||
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
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]) })
|
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func registerPodcastRoutes(cfg *Config) {
|
|||||||
// refreshed partial directly — no event indirection to miss. The
|
// refreshed partial directly — no event indirection to miss. The
|
||||||
// view assembly lives here (templates) while the action logic lives
|
// view assembly lives here (templates) while the action logic lives
|
||||||
// in the handler; this closure is the sanctioned seam.
|
// 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)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -48,6 +48,14 @@ func registerPodcastRoutes(cfg *Config) {
|
|||||||
return err
|
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())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -316,7 +317,9 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
|
|||||||
rowID := uuid.UUID(ep.ID.Bytes).String()
|
rowID := uuid.UUID(ep.ID.Bytes).String()
|
||||||
p := progressByItem[rowID]
|
p := progressByItem[rowID]
|
||||||
dateDisplay := ""
|
dateDisplay := ""
|
||||||
|
dateSort := ""
|
||||||
if ep.DatePublished.Valid {
|
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")
|
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
|
||||||
}
|
}
|
||||||
rows = append(rows, templates.PodcastEpisodeRow{
|
rows = append(rows, templates.PodcastEpisodeRow{
|
||||||
@@ -325,6 +328,7 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
|
|||||||
Title: ep.Title,
|
Title: ep.Title,
|
||||||
Description: firstLine(ep.Description.String, 200),
|
Description: firstLine(ep.Description.String, 200),
|
||||||
Date: dateDisplay,
|
Date: dateDisplay,
|
||||||
|
SortDate: dateSort,
|
||||||
Position: int(ep.FeedPosition.Int32),
|
Position: int(ep.FeedPosition.Int32),
|
||||||
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
|
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
|
||||||
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
||||||
@@ -337,17 +341,13 @@ func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User, f
|
|||||||
}
|
}
|
||||||
// Order override reshapes the LIST too (the sequence endpoint is the
|
// Order override reshapes the LIST too (the sequence endpoint is the
|
||||||
// strict downloaded-only view; the list shows everything in the
|
// strict downloaded-only view; the list shows everything in the
|
||||||
// chosen order so pending episodes stay visible).
|
// chosen order so pending episodes stay visible). Consolidated sort
|
||||||
if order == "oldest" || order == "newest" {
|
// (§0 item 5a) — SortDate is the lexical pub-date key so the
|
||||||
sort.SliceStable(rows, func(a, b int) bool {
|
// tiebreak is chronological even in display-tz date strings.
|
||||||
ka := rows[a].Date + itoaPad(rows[a].Position)
|
services.OrderEpisodes(rows, order,
|
||||||
kb := rows[b].Date + itoaPad(rows[b].Position)
|
func(r templates.PodcastEpisodeRow) int32 { return int32(r.Position) },
|
||||||
if order == "newest" {
|
func(r templates.PodcastEpisodeRow) string { return r.SortDate },
|
||||||
return ka > kb
|
)
|
||||||
}
|
|
||||||
return ka < kb
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
detail := &templates.PodcastDetailData{
|
detail := &templates.PodcastDetailData{
|
||||||
Feed: card,
|
Feed: card,
|
||||||
@@ -396,12 +396,11 @@ func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
|||||||
if err := json.Unmarshal(row, &v); err != nil {
|
if err := json.Unmarshal(row, &v); err != nil {
|
||||||
return "native"
|
return "native"
|
||||||
}
|
}
|
||||||
switch v.Order {
|
if !services.ValidPodcastEpisodeOrder(v.Order) {
|
||||||
case "oldest", "newest":
|
|
||||||
return v.Order
|
|
||||||
}
|
|
||||||
return "native"
|
return "native"
|
||||||
}
|
}
|
||||||
|
return v.Order
|
||||||
|
}
|
||||||
|
|
||||||
// facetValuesFromCards derives the filter bar values.
|
// facetValuesFromCards derives the filter bar values.
|
||||||
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
|
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
|
||||||
@@ -455,7 +454,6 @@ func itoaPad(n int) string {
|
|||||||
return fmt.Sprintf("%06d", n)
|
return fmt.Sprintf("%06d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// parsePodcastUUID parses an id path param (handlers.parseUUID is
|
// parsePodcastUUID parses an id path param (handlers.parseUUID is
|
||||||
// package-private).
|
// package-private).
|
||||||
func parsePodcastUUID(s string) (pgtype.UUID, error) {
|
func parsePodcastUUID(s string) (pgtype.UUID, error) {
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -186,7 +184,7 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
|
|||||||
var v struct {
|
var v struct {
|
||||||
Order string `json:"order"`
|
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
|
order = v.Order
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,17 +213,11 @@ func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.Me
|
|||||||
pos: ep.FeedPosition.Int32,
|
pos: ep.FeedPosition.Int32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.SliceStable(ordered, func(a, b int) bool {
|
// Effective listening order (order consolidation §0 item 5a).
|
||||||
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
|
services.OrderEpisodes(ordered, order,
|
||||||
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
|
func(e seqEp) int32 { return e.pos },
|
||||||
if order == "newest" {
|
func(e seqEp) string { return e.date },
|
||||||
return ka > kb
|
)
|
||||||
}
|
|
||||||
if order == "oldest" {
|
|
||||||
return ka < kb
|
|
||||||
}
|
|
||||||
return ordered[a].pos < ordered[b].pos
|
|
||||||
})
|
|
||||||
|
|
||||||
idx := -1
|
idx := -1
|
||||||
for i, ep := range ordered {
|
for i, ep := range ordered {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -116,21 +116,24 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
||||||
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
<option value="reverse" selected={ data.Order == "reverse" }>Feed order (reversed)</option>
|
||||||
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
|
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first (pub date)</option>
|
||||||
|
<option value="newest" selected={ data.Order == "newest" }>Newest first (pub date)</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Serial suggestion (D5): one-click, never silent -->
|
<!-- Serial suggestion (D5, §0 item 3 rename): one-click,
|
||||||
|
never silent. "reverse" sorts by FEED POSITION DESC —
|
||||||
|
no pub dates involved, so messy dates can't break it. -->
|
||||||
if data.IsSerial && data.Order == "native" {
|
if data.IsSerial && data.Order == "native" {
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary text-sm"
|
class="btn btn-secondary text-sm"
|
||||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||||
hx-vals='{"order": "oldest"}'
|
hx-vals='{"order": "reverse"}'
|
||||||
hx-target="#episode-list"
|
hx-target="#episode-list"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
title="Serial feed: publishers list newest first — listen oldest-first"
|
title="Serial feed: publishers list newest first — reverse the feed order to start at chapter 1"
|
||||||
>
|
>
|
||||||
<span>Serial? Listen oldest-first</span>
|
<span>Reverse feed order</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +385,27 @@ templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
<select
|
||||||
|
id="podcast-order-select"
|
||||||
|
name="order"
|
||||||
|
hx-swap-oob="true"
|
||||||
|
class="input w-auto py-1.5 cursor-pointer"
|
||||||
|
hx-put={ "/api/podcasts/feeds/" + feedID + "/order" }
|
||||||
|
hx-target="#episode-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<option value="native" selected={ order == "native" }>Feed order</option>
|
||||||
|
<option value="reverse" selected={ order == "reverse" }>Feed order (reversed)</option>
|
||||||
|
<option value="oldest" selected={ order == "oldest" }>Oldest first (pub date)</option>
|
||||||
|
<option value="newest" selected={ order == "newest" }>Newest first (pub date)</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
|
||||||
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
||||||
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
||||||
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
||||||
|
|||||||
+402
-291
File diff suppressed because it is too large.
Load diff
@@ -86,6 +86,7 @@ type PodcastEpisodeRow struct {
|
|||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
Date string // display-formatted (user tz)
|
Date string // display-formatted (user tz)
|
||||||
|
SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering
|
||||||
Position int
|
Position int
|
||||||
Duration string // human ("58:02" / "1:02:10")
|
Duration string // human ("58:02" / "1:02:10")
|
||||||
Size string // human MB/GB when known
|
Size string // human MB/GB when known
|
||||||
|
|||||||
Reference in new issue
Block a user