The detail header's listening-order select persisted fine and the reader chain honored it, but the page's card list never applied chain_order (it kept the composed query's title order) — so flipping the select changed nothing visible, and Play as Sequence launches from the FIRST CARD ON THE PAGE, making the played start (and the perceived order) disagree with the curated setting (2026-11-01 user report on the smart podcast page). - Smart episode collections: the page's match list re-orders through OrderEpisodeMedia (the same OrderEpisodes comparator the sequence producer uses — §10 R2's single source of order truth) before card building. Books-scope matches keep title order. - Static episode playlists: the sequence now honors the curated order too — native keeps the manual position order (SQL), reverse flips it, oldest/newest re-key on publication date with feed position as tiebreak. Previously statics ignored chain_order entirely while the select rendered on their pages. Pins: native page order (feed positions) and the reverse flip re-ordering the SSR cards, asserted in the curated-order subtest alongside the existing reader-chain pins. Podcast/playlist/audiobook/ RLS/dashboard/contract slices green.
88 lines
2.8 KiB
Go
88 lines
2.8 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"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])
|
|
})
|
|
}
|
|
}
|
|
|
|
// OrderEpisodeMedia applies the curated listening order to a media
|
|
// match list — the SMART collection page renders exactly what the
|
|
// sequence will play (2026-11-01 user report: flipping the order select
|
|
// changed nothing on the page, and Play launches from the first card).
|
|
// Non-episode match lists are left untouched (title order stays).
|
|
func OrderEpisodeMedia(items []database.MediaItems, order string) {
|
|
anyEpisode := false
|
|
for _, m := range items {
|
|
if m.Source == "podcast_episode" {
|
|
anyEpisode = true
|
|
break
|
|
}
|
|
}
|
|
if !anyEpisode {
|
|
return
|
|
}
|
|
OrderEpisodes(items, order,
|
|
func(m database.MediaItems) int32 { return m.FeedPosition.Int32 },
|
|
func(m database.MediaItems) string {
|
|
if m.DatePublished.Valid {
|
|
return m.DatePublished.Time.Format("2006-01-02")
|
|
}
|
|
return ""
|
|
})
|
|
}
|
|
|
|
// episodePosKey: zero-padded position so lexical date keys tiebreak
|
|
// numerically.
|
|
func episodePosKey(pos int32) string {
|
|
return fmt.Sprintf("%06d", pos)
|
|
}
|