From 69dde62fcdaae0790e7fbb4e31b3c687bdbd0bc8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:45:48 -0400 Subject: [PATCH] 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 --- internal/catalog/catalog_resolver.go | 75 ++++++++++-- internal/catalog/query_definition.go | 20 ++++ internal/sections/fetcher.go | 108 ++++++++++++++++- .../sections/fetcher_personal_list_test.go | 109 ++++++++++++++++++ .../RecipeGallery/RecipeParamFields.tsx | 41 ++++++- 5 files changed, 337 insertions(+), 16 deletions(-) diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index a659af5a..e424aad2 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -377,8 +377,9 @@ func (r *CatalogResolver) resolveSectionSource(ctx context.Context, req CatalogR if section.SectionType == "watchlist" { source = CatalogSourceWatchlist } - // Personal list sections may carry optional type/library filters; - // apply them as an overlay query so the "see all" page matches the rail. + // Personal list sections may carry optional type/library filters and a + // sort; apply them as an overlay query so the "see all" page matches + // the rail. Without a configured sort the stored list order is kept. sectionFilters := parseCatalogSectionFilters(section.Config) query := QueryDefinition{ MediaScope: sectionFilters.FilterType, @@ -387,13 +388,21 @@ func (r *CatalogResolver) resolveSectionSource(ctx context.Context, req CatalogR if section.Scope == "library" && section.LibraryID != nil { query.LibraryIDs = []int{*section.LibraryID} } + useSourceOrder := true + if qs, ok := NormalizePersonalListSort(parseCatalogSectionSort(section.Config)); ok { + query.Sort = qs + // added_at (date added to the list) is applied by + // loadPersonalSourceIDs, which keeps the source order path; + // metadata sorts go through the query executor instead. + useSourceOrder = qs.Field == "added_at" + } return r.resolvePersonalSource(ctx, CatalogRequest{ Source: source, Query: query, Limit: req.Limit, Offset: req.Offset, SkipTotal: req.SkipTotal, - UseSourceOrder: true, + UseSourceOrder: useSourceOrder, }, access) case "recently_added": return r.resolveSectionBrowseSource(ctx, req, access, section, "added_at", "desc") @@ -1570,6 +1579,19 @@ func parseCatalogSectionFilters(config json.RawMessage) catalogSectionFilters { return filters } +// parseCatalogSectionSort extracts the optional flat sort/order keys from a +// watchlist/favorites section config. +func parseCatalogSectionSort(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 +} + func normalizeCatalogSectionLibraryIDs(single *int, multiple []int) []int { seen := map[int]struct{}{} result := make([]int, 0, len(multiple)+1) @@ -1676,6 +1698,41 @@ func catalogProfileCanAccessCollection(collection *userstore.Collection, profile return false } +// PersonalListEntry pairs a list member with the timestamp it was added to +// the list (watchlist/favorites), as stored by the user store. +type PersonalListEntry struct { + ID string + AddedAt string +} + +// OrderPersonalListIDs returns the entry IDs, reordered by list added_at when +// that sort is requested; any other (or no) sort keeps the stored list order. +// AddedAt strings from one store share a format, so lexicographic comparison +// preserves chronology; entries with no timestamp sort last. +func OrderPersonalListIDs(entries []PersonalListEntry, qs QuerySort) []string { + if qs.Field == "added_at" { + asc := qs.Order == "asc" + slices.SortStableFunc(entries, func(a, b PersonalListEntry) int { + if (a.AddedAt == "") != (b.AddedAt == "") { + if a.AddedAt != "" { + return -1 + } + return 1 + } + cmp := strings.Compare(a.AddedAt, b.AddedAt) + if !asc { + cmp = -cmp + } + return cmp + }) + } + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + ids = append(ids, entry.ID) + } + return ids +} + func (r *CatalogResolver) loadPersonalSourceIDs(ctx context.Context, store userstore.UserStore, req CatalogRequest, profileID string) ([]string, error) { switch req.Source { case CatalogSourceFavorites: @@ -1683,21 +1740,21 @@ func (r *CatalogResolver) loadPersonalSourceIDs(ctx context.Context, store users if err != nil { return nil, err } - ids := make([]string, 0, len(entries)) + listed := make([]PersonalListEntry, 0, len(entries)) for _, entry := range entries { - ids = append(ids, entry.MediaItemID) + listed = append(listed, PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt}) } - return ids, nil + return OrderPersonalListIDs(listed, req.Query.Sort), nil case CatalogSourceWatchlist: entries, err := store.ListWatchlist(ctx, profileID, 10000, 0) if err != nil { return nil, err } - ids := make([]string, 0, len(entries)) + listed := make([]PersonalListEntry, 0, len(entries)) for _, entry := range entries { - ids = append(ids, entry.MediaItemID) + listed = append(listed, PersonalListEntry{ID: entry.MediaItemID, AddedAt: entry.AddedAt}) } - return ids, nil + return OrderPersonalListIDs(listed, req.Query.Sort), nil case CatalogSourceHistory: entries, err := store.ListHistory(ctx, profileID, 10000, 0) if err != nil { diff --git a/internal/catalog/query_definition.go b/internal/catalog/query_definition.go index 0075f4fe..f4d0c1f4 100644 --- a/internal/catalog/query_definition.go +++ b/internal/catalog/query_definition.go @@ -190,6 +190,26 @@ type QuerySort struct { Order string `json:"order"` } +// NormalizePersonalListSort validates the optional sort configured on a +// watchlist/favorites section and applies the field's default order when none +// is given. Returns false when the field is empty or unsupported, meaning the +// list's stored order should be kept. "added_at" means the date the item was +// added to the list (not to the library) and is resolved from the list +// entries rather than the query executor. +func NormalizePersonalListSort(field, order string) (QuerySort, bool) { + field = strings.ToLower(strings.TrimSpace(field)) + switch field { + case "title", "release_date", "year", "rating_imdb", "added_at": + default: + return QuerySort{}, false + } + order = strings.ToLower(strings.TrimSpace(order)) + if order != "asc" && order != "desc" { + order = querySortDefs[field].defaultOrder + } + return QuerySort{Field: field, Order: order}, true +} + func (q QueryDefinition) Normalize() QueryDefinition { normalized := q normalized.MediaScope = strings.ToLower(strings.TrimSpace(normalized.MediaScope)) diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 7ff63d6b..833351eb 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -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) { diff --git a/internal/sections/fetcher_personal_list_test.go b/internal/sections/fetcher_personal_list_test.go index ce9df865..1deb43b0 100644 --- a/internal/sections/fetcher_personal_list_test.go +++ b/internal/sections/fetcher_personal_list_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" ) // Watchlist and favorites sections are profile-scoped: with no user store (or @@ -25,3 +26,111 @@ func TestFetchSectionPersonalListWithoutStoreReturnsEmpty(t *testing.T) { } } } + +func TestSortPersonalListItems(t *testing.T) { + t.Parallel() + + f64 := func(v float64) *float64 { return &v } + str := func(v string) *string { return &v } + build := func() []*models.MediaItem { + return []*models.MediaItem{ + {ContentID: "b", Title: "Beta", ReleaseDate: str("2020-01-01"), RatingIMDB: f64(6.1)}, + {ContentID: "a", Title: "alpha", ReleaseDate: str("2024-05-05"), RatingIMDB: nil}, + {ContentID: "c", Title: "Gamma", FirstAirDate: str("2022-03-03"), RatingIMDB: f64(8.4)}, + {ContentID: "d", Title: "Delta", RatingIMDB: f64(7.0)}, + } + } + + cases := []struct { + field, order string + want []string + }{ + {"title", "asc", []string{"a", "b", "d", "c"}}, + {"title", "desc", []string{"c", "d", "b", "a"}}, + // FirstAirDate is used when ReleaseDate is missing; unknown dates last. + {"release_date", "desc", []string{"a", "c", "b", "d"}}, + {"release_date", "asc", []string{"b", "c", "a", "d"}}, + // Missing ratings sort last regardless of direction. + {"rating_imdb", "desc", []string{"c", "d", "b", "a"}}, + {"rating_imdb", "asc", []string{"b", "d", "c", "a"}}, + } + for _, tc := range cases { + qs, ok := catalog.NormalizePersonalListSort(tc.field, tc.order) + if !ok { + t.Fatalf("NormalizePersonalListSort(%s, %s) not ok", tc.field, tc.order) + } + items := build() + sortPersonalListItems(items, qs) + got := make([]string, len(items)) + for i, item := range items { + got[i] = item.ContentID + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("sort %s %s = %v, want %v", tc.field, tc.order, got, tc.want) + } + } + } +} + +func TestNormalizePersonalListSortDefaultsAndRejects(t *testing.T) { + t.Parallel() + + if qs, ok := catalog.NormalizePersonalListSort("title", ""); !ok || qs.Order != "asc" { + t.Fatalf("title default = %+v, %v; want asc", qs, ok) + } + if qs, ok := catalog.NormalizePersonalListSort("release_date", ""); !ok || qs.Order != "desc" { + t.Fatalf("release_date default = %+v, %v; want desc", qs, ok) + } + if _, ok := catalog.NormalizePersonalListSort("", "asc"); ok { + t.Fatal("empty field must keep list order") + } + if _, ok := catalog.NormalizePersonalListSort("progress", ""); ok { + t.Fatal("personalized/unsupported fields must be rejected") + } +} + +// added_at sorts by when the item was added to the list, from the entry +// timestamps rather than item metadata; missing timestamps sort last and any +// other sort keeps the stored list order. +func TestOrderPersonalListIDsByAddedAt(t *testing.T) { + t.Parallel() + + build := func() []catalog.PersonalListEntry { + return []catalog.PersonalListEntry{ + {ID: "synced", AddedAt: "2024-02-02T00:00:00Z"}, + {ID: "old", AddedAt: "2023-01-01T00:00:00Z"}, + {ID: "new", AddedAt: "2025-06-06T00:00:00Z"}, + {ID: "unknown", AddedAt: ""}, + } + } + + qs, ok := catalog.NormalizePersonalListSort("added_at", "") + if !ok || qs.Order != "desc" { + t.Fatalf("added_at default = %+v, %v; want desc", qs, ok) + } + + got := catalog.OrderPersonalListIDs(build(), qs) + want := []string{"new", "synced", "old", "unknown"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("added_at desc = %v, want %v", got, want) + } + } + + got = catalog.OrderPersonalListIDs(build(), catalog.QuerySort{Field: "added_at", Order: "asc"}) + want = []string{"old", "synced", "new", "unknown"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("added_at asc = %v, want %v", got, want) + } + } + + got = catalog.OrderPersonalListIDs(build(), catalog.QuerySort{}) + want = []string{"synced", "old", "new", "unknown"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("no sort = %v, want stored order %v", got, want) + } + } +} diff --git a/web/src/components/RecipeGallery/RecipeParamFields.tsx b/web/src/components/RecipeGallery/RecipeParamFields.tsx index 9037839d..508ebd17 100644 --- a/web/src/components/RecipeGallery/RecipeParamFields.tsx +++ b/web/src/components/RecipeGallery/RecipeParamFields.tsx @@ -117,14 +117,36 @@ interface ParamFieldProps { onChange: (next: Record) => void; } +// Sort choices for watchlist/favorites sections. The empty value keeps the +// list's stored order (provider sync order, then newest-added first). +const PERSONAL_LIST_SORT_OPTIONS = [ + { value: "", label: "List order (default)" }, + { value: "added_at:desc", label: "Date added (newest first)" }, + { value: "added_at:asc", label: "Date added (oldest first)" }, + { value: "title:asc", label: "Title (A–Z)" }, + { value: "title:desc", label: "Title (Z–A)" }, + { value: "release_date:desc", label: "Release date (newest first)" }, + { value: "release_date:asc", label: "Release date (oldest first)" }, + { value: "rating_imdb:desc", label: "IMDb rating (highest first)" }, +]; + // PersonalListFilterFields edits the optional filter_type / filter_library_ids -// filters for watchlist and favorites sections, e.g. a "Movies watchlist" rail. +// filters and sort for watchlist and favorites sections, e.g. a "Movies +// watchlist" rail sorted by release date. function PersonalListFilterFields({ params, onChange }: ParamFieldProps) { const { data: libraries } = useAvailableUserLibraries(); const filterType = typeof params.filter_type === "string" ? params.filter_type : ""; const libraryIds = Array.isArray(params.filter_library_ids) ? params.filter_library_ids.filter((id): id is number => typeof id === "number") : []; + const sortField = typeof params.sort === "string" ? params.sort : ""; + const sortOrder = + typeof params.order === "string" && params.order + ? params.order + : sortField === "title" + ? "asc" // mirror the backend's per-field default order + : "desc"; + const sortValue = sortField ? `${sortField}:${sortOrder}` : ""; return (
@@ -156,6 +178,23 @@ function PersonalListFilterFields({ params, onChange }: ParamFieldProps) { } /> +
); }