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
+66 -9
View File
@@ -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 {
+20
View File
@@ -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))
+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) {
@@ -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)
}
}
}
@@ -117,14 +117,36 @@ interface ParamFieldProps {
onChange: (next: Record<string, unknown>) => 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 (AZ)" },
{ value: "title:desc", label: "Title (ZA)" },
{ 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 (
<div className="grid gap-4 md:grid-cols-2">
@@ -156,6 +178,23 @@ function PersonalListFilterFields({ params, onChange }: ParamFieldProps) {
}
/>
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-xs text-white/70">Sort</span>
<select
value={PERSONAL_LIST_SORT_OPTIONS.some((o) => o.value === sortValue) ? sortValue : ""}
onChange={(e) => {
const [sort, order] = e.target.value.split(":");
onChange({ ...params, sort: sort || undefined, order: order || undefined });
}}
className="w-full rounded border border-white/15 bg-white/5 px-3 py-2 text-sm"
>
{PERSONAL_LIST_SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
);
}