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
+708
-463
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user