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:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 (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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user