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) }