feat(sections): configurable sort for watchlist/favorites sections

Watchlist and favorites sections keep their stored list order by default
(provider-synced positions first, then newest-added). An optional
sort/order config now supports title, release date, IMDb rating, and
date-added-to-list ordering, applied consistently on the home rail and
the catalog "see all" page. added_at is resolved from the list entries
via a shared OrderPersonalListIDs helper since it is distinct from the
catalog's library added_at. The section editor gains a Sort dropdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-05 18:45:48 -04:00
co-authored by Claude Fable 5
parent 42602b7896
commit 69dde62fcd
5 changed files with 337 additions and 16 deletions
+102 -6
View File
@@ -1485,33 +1485,38 @@ func (f *Fetcher) fetchPersonalListSection(ctx context.Context, s ResolvedSectio
return nil, 0, fmt.Errorf("getting user store for %s section: %w", s.SectionType, err)
}
var contentIDs []string
var listed []catalog.PersonalListEntry
switch s.SectionType {
case SectionWatchlist:
entries, err := store.ListWatchlist(ctx, profileID, personalListFetchLimit, 0)
if err != nil {
return nil, 0, fmt.Errorf("listing watchlist: %w", err)
}
contentIDs = make([]string, 0, len(entries))
listed = make([]catalog.PersonalListEntry, 0, len(entries))
for _, entry := range entries {
contentIDs = append(contentIDs, entry.MediaItemID)
listed = append(listed, catalog.PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt})
}
case SectionFavorites:
entries, err := store.ListFavorites(ctx, profileID, personalListFetchLimit, 0)
if err != nil {
return nil, 0, fmt.Errorf("listing favorites: %w", err)
}
contentIDs = make([]string, 0, len(entries))
listed = make([]catalog.PersonalListEntry, 0, len(entries))
for _, entry := range entries {
contentIDs = append(contentIDs, entry.MediaItemID)
listed = append(listed, catalog.PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt})
}
default:
return nil, 0, fmt.Errorf("unsupported personal list section type: %s", s.SectionType)
}
if len(contentIDs) == 0 {
if len(listed) == 0 {
return []*models.MediaItem{}, 0, nil
}
// added_at (date added to the list) reorders the entry IDs; the metadata
// sorts are applied to the resolved items further down.
qs, hasSort := catalog.NormalizePersonalListSort(parsePersonalListSort(s.Config))
contentIDs := catalog.OrderPersonalListIDs(listed, qs)
items, err := f.fetchItemsByContentIDsFiltered(ctx, contentIDs, libraryID, libraryIDs, ParseConfigFilters(s.Config), filter)
if err != nil {
return nil, 0, err
@@ -1528,10 +1533,101 @@ func (f *Fetcher) fetchPersonalListSection(ctx context.Context, s ResolvedSectio
}
}
if hasSort && qs.Field != "added_at" {
sortPersonalListItems(ordered, qs)
}
ordered, total := limitUserCollectionSectionItems(ordered, s.ItemLimit)
return ordered, total, nil
}
// parsePersonalListSort extracts the optional flat sort/order keys from a
// watchlist/favorites section config.
func parsePersonalListSort(config json.RawMessage) (string, string) {
var cfg struct {
Sort string `json:"sort"`
Order string `json:"order"`
}
if len(config) > 0 {
_ = json.Unmarshal(config, &cfg)
}
return cfg.Sort, cfg.Order
}
// sortPersonalListItems reorders a personal list rail by the configured sort.
// Items missing the sort field's value go last regardless of direction.
func sortPersonalListItems(items []*models.MediaItem, qs catalog.QuerySort) {
desc := qs.Order == "desc"
sort.SliceStable(items, func(i, j int) bool {
a, b := items[i], items[j]
switch qs.Field {
case "title":
at, bt := personalListTitleKey(a), personalListTitleKey(b)
if desc {
return at > bt
}
return at < bt
case "release_date":
return personalListLess(personalListReleaseKey(a), personalListReleaseKey(b), desc)
case "year":
return personalListLess(personalListYearKey(a), personalListYearKey(b), desc)
case "rating_imdb":
return personalListLess(personalListRatingKey(a), personalListRatingKey(b), desc)
default:
return false
}
})
}
func personalListTitleKey(item *models.MediaItem) string {
if title := strings.TrimSpace(item.SortTitle); title != "" {
return strings.ToLower(title)
}
return strings.ToLower(strings.TrimSpace(item.Title))
}
// personalListReleaseKey returns an ISO date string (lexicographically
// ordered), preferring release_date and falling back to a series' first air
// date. Empty means unknown.
func personalListReleaseKey(item *models.MediaItem) string {
if item.ReleaseDate != nil && strings.TrimSpace(*item.ReleaseDate) != "" {
return strings.TrimSpace(*item.ReleaseDate)
}
if item.FirstAirDate != nil {
return strings.TrimSpace(*item.FirstAirDate)
}
return ""
}
func personalListYearKey(item *models.MediaItem) string {
if item.Year <= 0 {
return ""
}
return fmt.Sprintf("%04d", item.Year)
}
func personalListRatingKey(item *models.MediaItem) string {
if item.RatingIMDB == nil {
return ""
}
return fmt.Sprintf("%08.3f", *item.RatingIMDB)
}
// personalListLess compares string sort keys where "" means the item has no
// value for the field; missing values always sort last.
func personalListLess(a, b string, desc bool) bool {
if (a == "") != (b == "") {
return a != ""
}
if a == b {
return false
}
if desc {
return a > b
}
return a < b
}
func limitUserCollectionSectionItems(items []*models.MediaItem, limit int) ([]*models.MediaItem, int) {
total := len(items)
if limit > 0 && limit < len(items) {