From e3675536aab45cb590e04a42e172f3656259db9f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 09:15:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(podcasts):=20reverse=20order=20=E2=80=94?= =?UTF-8?q?=20position=20DESC,=20date-agnostic;=20single=20order=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/podcast_test.go | 92 +++- internal/handlers/podcast.go | 53 +-- internal/router/podcast.go | 10 +- internal/router/podcast_page.go | 162 ++++--- internal/router/reader.go | 20 +- internal/services/podcast_order.go | 60 +++ templates/podcast_detail.templ | 36 +- templates/podcast_detail_templ.go | 737 +++++++++++++++++------------ templates/podcast_types.go | 1 + 9 files changed, 708 insertions(+), 463 deletions(-) create mode 100644 internal/services/podcast_order.go 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 + } + } else if data.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" hx-confirm=\"Unsubscribe from this feed? Episodes and files are kept.\" hx-swap=\"none\">Unsubscribe ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - if data.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "Remove Feed") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.GapCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -532,25 +545,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, 53, "

") + 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.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

Playback skips them and keeps going — use Download All to fill the gaps.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -565,7 +578,7 @@ func PodcastDetailPage(user User, data PodcastDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -573,7 +586,7 @@ 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, 57, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -600,35 +613,35 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var28 := templ.GetChildren(ctx) - if templ_7745c5c3_Var28 == nil { - templ_7745c5c3_Var28 = templ.NopComponent + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

Episodes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" hx-trigger=\"podcast:changed from:body\" hx-swap=\"outerHTML\">

Episodes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(data.Episodes) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
No episodes yet — hit Refresh to check the feed.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
No episodes yet — hit Refresh to check the feed.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -638,12 +651,12 @@ func PodcastEpisodeList(data PodcastDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -670,25 +683,25 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var30 := templ.GetChildren(ctx) - if templ_7745c5c3_Var30 == nil { - templ_7745c5c3_Var30 = templ.NopComponent + templ_7745c5c3_Var31 := templ.GetChildren(ctx) + if templ_7745c5c3_Var31 == nil { + templ_7745c5c3_Var31 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "

Episodes

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" hx-trigger=\"podcast:changed from:body, every 2s\" hx-swap=\"outerHTML\">

Episodes

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -698,7 +711,7 @@ func PodcastEpisodeListPolling(data PodcastDetailData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -722,254 +735,254 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var32 := templ.GetChildren(ctx) - if templ_7745c5c3_Var32 == nil { - templ_7745c5c3_Var32 = templ.NopComponent + templ_7745c5c3_Var33 := templ.GetChildren(ctx) + if templ_7745c5c3_Var33 == nil { + templ_7745c5c3_Var33 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(ep.Position)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 270, Col: 28} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" class=\"font-medium text-sm hover:underline line-clamp-1\" style=\"color: var(--text-primary)\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 277, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.DownloadState == "failed" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 276, Col: 165} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 278, Col: 134} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 279, Col: 165} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if ep.DownloadState == "pending" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") + } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 280, Col: 133} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 281, Col: 134} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if ep.DownloadError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "

") + } else if ep.DownloadState == "pending" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(ep.StateBadge()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 284, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 283, Col: 133} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.DownloadError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(ep.DownloadError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 287, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if ep.DownloadState == "downloading" && ep.ProgressKnown { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.ProgressTotal > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived)) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 299, Col: 122} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 296, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(ep.ProgressReceived)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 302, Col: 122} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Date) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 304, Col: 13} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 307, Col: 13} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if ep.Duration != "" { - var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 306, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if ep.Size != "" { var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Size) + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + ep.Duration) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 309, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 309, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if ep.Downloaded { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ep.Downloaded { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -977,12 +990,12 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "Play") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "Play") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -990,60 +1003,60 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 = []any{"btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + var templ_7745c5c3_Var48 = []any{"btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var48...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1125,64 +1138,162 @@ func PodcastAutoDownloadSelect(feed PodcastFeedCard) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var52 := templ.GetChildren(ctx) - if templ_7745c5c3_Var52 == nil { - templ_7745c5c3_Var52 = templ.NopComponent + templ_7745c5c3_Var53 := templ.GetChildren(ctx) + if templ_7745c5c3_Var53 == nil { + templ_7745c5c3_Var53 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">All episodes ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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). +func PodcastOrderSelectOOB(feedID string, order string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var58 := templ.GetChildren(ctx) + if templ_7745c5c3_Var58 == nil { + templ_7745c5c3_Var58 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1208,64 +1319,64 @@ func PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var57 := templ.GetChildren(ctx) - if templ_7745c5c3_Var57 == nil { - templ_7745c5c3_Var57 = templ.NopComponent + templ_7745c5c3_Var64 := templ.GetChildren(ctx) + if templ_7745c5c3_Var64 == nil { + templ_7745c5c3_Var64 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">Newest") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1293,51 +1404,51 @@ func PodcastUnsubscribeModal(data PodcastDetailData) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var62 := templ.GetChildren(ctx) - if templ_7745c5c3_Var62 == nil { - templ_7745c5c3_Var62 = templ.NopComponent + templ_7745c5c3_Var69 := templ.GetChildren(ctx) + if templ_7745c5c3_Var69 == nil { + templ_7745c5c3_Var69 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

Unsubscribe from ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

Unsubscribe from ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var63 string - templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(data.Feed.DisplayTitle()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 418, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcast_detail.templ`, Line: 442, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "?

You are the only subscriber.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" hx-swap=\"none\" @click=\"unsubOpen = false\">Remove feed — delete episodes and files

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/podcast_types.go b/templates/podcast_types.go index e677a561..1cb30cde 100644 --- a/templates/podcast_types.go +++ b/templates/podcast_types.go @@ -86,6 +86,7 @@ type PodcastEpisodeRow struct { Title string Description string Date string // display-formatted (user tz) + SortDate string // lexical-comparable pub-date key (2006-01-02) for ordering Position int Duration string // human ("58:02" / "1:02:10") Size string // human MB/GB when known