Files
silo-server/internal/catalog/catalog_parser.go
4d99597966 feat(watchlist): honor provider list order by default (#352)
The watchlist catalog page silently returned the stored list order while
its sort dropdown claimed "Date Added", and mirroring a provider's list
order (e.g. MDBList) was off by default, so synced watchlists appeared
in first-sync-time order with no way to tell what was happening.

- Web: the watchlist source now uses the same source-order sentinel as
  collections — the dropdown shows "List Order" as the default, and an
  explicit "Date Added" pick sends sort=added_at instead of being
  stripped (previously indistinguishable from the default).
- Server: on personal lists (watchlist/favorites) an explicit added_at
  sort now takes the source-order path, where added_at means "date
  added to the list"; the query executor path sorted by the library's
  created_at instead. History keeps the executor path since its ID
  loading ignores the sort.
- watchsync: new connections default sync_watchlist_order_enabled to
  true, with a migration flipping existing rows to match. Providers
  without the provides_watchlist_order capability ignore the flag at
  sync time.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:28:06 -04:00

452 lines
13 KiB
Go

package catalog
import (
"fmt"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"time"
)
var (
catalogGroupMatchPattern = regexp.MustCompile(`^groups\[(\d+)\]\[match\]$`)
catalogGroupRulePattern = regexp.MustCompile(`^groups\[(\d+)\]\[rules\]\[(\d+)\]\[(field|op|value)\]$`)
catalogGroupRuleValuePattern = regexp.MustCompile(`^groups\[(\d+)\]\[rules\]\[(\d+)\]\[value\]\[(\d+)\]$`)
)
type catalogOverlay struct {
searchQuery string
namePrefix string
query QueryDefinition
hasExplicitSort bool
}
type catalogGroupBuilder struct {
match string
rules map[int]*catalogRuleBuilder
}
type catalogRuleBuilder struct {
field string
op string
values []any
indexedValues map[int]any
}
// ParseCatalogRequest converts catalog URL params into a normalized request.
func ParseCatalogRequest(values url.Values) (CatalogRequest, error) {
req := CatalogRequest{
Source: CatalogSource(strings.ToLower(strings.TrimSpace(values.Get("source")))),
Limit: 20,
}
if req.Source == "" {
req.Source = CatalogSourceQuery
}
if limit := ParseIntParam(values.Get("limit")); limit > 0 {
req.Limit = limit
}
if req.Limit > 100 {
req.Limit = 100
}
req.Offset = max(ParseIntParam(values.Get("offset")), 0)
req.SkipTotal = parseCatalogSkipTotal(values.Get("include_total"))
if raw := strings.TrimSpace(values.Get("snapshot")); raw != "" {
t, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return CatalogRequest{}, fmt.Errorf("invalid snapshot timestamp: %w", err)
}
req.SnapshotAt = &t
}
switch req.Source {
case CatalogSourceQuery:
overlay, err := parseCatalogOverlay(values)
if err != nil {
return CatalogRequest{}, err
}
req.NamePrefix = overlay.namePrefix
req.SearchQuery = overlay.searchQuery
req.Query = overlay.query
req.UseSourceOrder = false
if !overlay.hasExplicitSort {
req.Query.Sort = defaultCatalogQuerySort(req.SearchQuery)
}
case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory:
overlay, err := parseCatalogOverlay(values)
if err != nil {
return CatalogRequest{}, err
}
req.NamePrefix = overlay.namePrefix
req.SearchQuery = overlay.searchQuery
req.Query = overlay.query
// On personal lists an explicit "added_at" sort means the date the item
// was added to the list, which loadPersonalSourceIDs applies on the
// source-order path; the query executor would sort by the library's
// created_at instead. History ID loading ignores the sort, so it stays
// on the executor path.
req.UseSourceOrder = !overlay.hasExplicitSort ||
(req.Source != CatalogSourceHistory && req.Query.Sort.Field == "added_at")
case CatalogSourcePerson:
req.PersonID = ParseInt64Param(values.Get("person_id"))
if req.PersonID <= 0 {
return CatalogRequest{}, fmt.Errorf("person_id is required")
}
overlay, err := parseCatalogOverlay(values)
if err != nil {
return CatalogRequest{}, err
}
req.NamePrefix = overlay.namePrefix
req.SearchQuery = overlay.searchQuery
req.Query = overlay.query
req.UseSourceOrder = !overlay.hasExplicitSort
case CatalogSourceSection:
scope := strings.ToLower(strings.TrimSpace(values.Get("scope")))
if scope == "" {
scope = "library"
}
if scope != "home" && scope != "library" {
return CatalogRequest{}, fmt.Errorf("scope must be 'home' or 'library'")
}
req.Scope = scope
req.SectionID = strings.TrimSpace(values.Get("section_id"))
if req.SectionID == "" {
return CatalogRequest{}, fmt.Errorf("section_id is required")
}
if scope == "library" {
libraryID := ParseIntParam(values.Get("library_id"))
if libraryID <= 0 {
return CatalogRequest{}, fmt.Errorf("library_id is required for library sections")
}
req.LibraryID = libraryID
}
if hasCatalogOverlayParams(values, scope == "library") {
return CatalogRequest{}, fmt.Errorf("source %q does not allow overlay params", req.Source)
}
req.UseSourceOrder = true
case CatalogSourceLibraryCollection, CatalogSourceUserCollection:
req.CollectionID = strings.TrimSpace(values.Get("collection_id"))
if req.CollectionID == "" {
return CatalogRequest{}, fmt.Errorf("collection_id is required")
}
overlay, err := parseCatalogOverlay(values)
if err != nil {
return CatalogRequest{}, err
}
req.NamePrefix = overlay.namePrefix
req.SearchQuery = overlay.searchQuery
req.Query = overlay.query
req.UseSourceOrder = !overlay.hasExplicitSort
default:
return CatalogRequest{}, fmt.Errorf("unsupported catalog source %q", req.Source)
}
return req, nil
}
func parseCatalogSkipTotal(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
includeTotal, err := strconv.ParseBool(raw)
if err != nil {
return false
}
return !includeTotal
}
func parseCatalogOverlay(values url.Values) (catalogOverlay, error) {
def := QueryDefinition{
Match: normalizeCatalogMatch(values.Get("match")),
Groups: []QueryGroup{},
}
searchQuery := strings.TrimSpace(values.Get("q"))
if libraryID := ParseIntParam(values.Get("library_id")); libraryID > 0 {
def.LibraryIDs = []int{libraryID}
}
if mediaScope := parseCatalogMediaScope(values.Get("type")); mediaScope != "" {
def.MediaScope = mediaScope
}
if queryLimit := ParseIntParam(values.Get("query_limit")); queryLimit > 0 {
def.Limit = &queryLimit
}
implicitRules := make([]QueryRule, 0, 4)
if genre := strings.TrimSpace(values.Get("genre")); genre != "" {
implicitRules = append(implicitRules, QueryRule{Field: "genre", Op: "contains", Value: genre})
}
if status := strings.TrimSpace(values.Get("status")); status != "" {
implicitRules = append(implicitRules, QueryRule{Field: "status", Op: "is", Value: status})
}
yearMin := ParseIntParam(values.Get("year_min"))
yearMax := ParseIntParam(values.Get("year_max"))
switch {
case yearMin > 0 && yearMax > 0:
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "between", Value: []any{yearMin, yearMax}})
case yearMin > 0:
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "gte", Value: yearMin})
case yearMax > 0:
implicitRules = append(implicitRules, QueryRule{Field: "year", Op: "lte", Value: yearMax})
}
if len(implicitRules) > 0 {
def.Groups = append(def.Groups, QueryGroup{
Match: "all",
Rules: implicitRules,
})
}
if ratings := ParseContentRatings(values.Get("content_rating")); len(ratings) == 1 {
def.Groups = append(def.Groups, QueryGroup{
Match: "all",
Rules: []QueryRule{{Field: "content_rating", Op: "is", Value: ratings[0]}},
})
} else if len(ratings) > 1 {
rules := make([]QueryRule, 0, len(ratings))
for _, rating := range ratings {
rules = append(rules, QueryRule{Field: "content_rating", Op: "is", Value: rating})
}
def.Groups = append(def.Groups, QueryGroup{Match: "any", Rules: rules})
}
explicitGroups, err := parseCatalogGroups(values)
if err != nil {
return catalogOverlay{}, err
}
def.Groups = append(def.Groups, explicitGroups...)
hasExplicitSort := values.Get("sort") != "" || values.Get("order") != ""
if hasExplicitSort {
def.Sort = normalizeExplicitCatalogSort(values.Get("sort"), values.Get("order"))
}
return catalogOverlay{
searchQuery: searchQuery,
namePrefix: strings.TrimSpace(values.Get("name_prefix")),
query: def,
hasExplicitSort: hasExplicitSort,
}, nil
}
func parseCatalogGroups(values url.Values) ([]QueryGroup, error) {
groupBuilders := map[int]*catalogGroupBuilder{}
for key, rawValues := range values {
if matches := catalogGroupMatchPattern.FindStringSubmatch(key); matches != nil {
groupIdx, _ := strconv.Atoi(matches[1])
builder := ensureCatalogGroupBuilder(groupBuilders, groupIdx)
if len(rawValues) > 0 {
builder.match = rawValues[0]
}
continue
}
if matches := catalogGroupRulePattern.FindStringSubmatch(key); matches != nil {
groupIdx, _ := strconv.Atoi(matches[1])
ruleIdx, _ := strconv.Atoi(matches[2])
fieldName := matches[3]
rule := ensureCatalogRuleBuilder(groupBuilders, groupIdx, ruleIdx)
switch fieldName {
case "field":
if len(rawValues) > 0 {
rule.field = rawValues[0]
}
case "op":
if len(rawValues) > 0 {
rule.op = rawValues[0]
}
case "value":
rule.values = make([]any, 0, len(rawValues))
for _, value := range rawValues {
rule.values = append(rule.values, parseCatalogScalar(value))
}
}
continue
}
if matches := catalogGroupRuleValuePattern.FindStringSubmatch(key); matches != nil {
groupIdx, _ := strconv.Atoi(matches[1])
ruleIdx, _ := strconv.Atoi(matches[2])
valueIdx, _ := strconv.Atoi(matches[3])
rule := ensureCatalogRuleBuilder(groupBuilders, groupIdx, ruleIdx)
if len(rawValues) == 0 {
continue
}
if rule.indexedValues == nil {
rule.indexedValues = map[int]any{}
}
rule.indexedValues[valueIdx] = parseCatalogScalar(rawValues[0])
}
}
if len(groupBuilders) == 0 {
return nil, nil
}
groupIndexes := make([]int, 0, len(groupBuilders))
for idx := range groupBuilders {
groupIndexes = append(groupIndexes, idx)
}
slices.Sort(groupIndexes)
groups := make([]QueryGroup, 0, len(groupIndexes))
for _, groupIdx := range groupIndexes {
builder := groupBuilders[groupIdx]
ruleIndexes := make([]int, 0, len(builder.rules))
for idx := range builder.rules {
ruleIndexes = append(ruleIndexes, idx)
}
slices.Sort(ruleIndexes)
rules := make([]QueryRule, 0, len(ruleIndexes))
for _, ruleIdx := range ruleIndexes {
ruleBuilder := builder.rules[ruleIdx]
rules = append(rules, QueryRule{
Field: ruleBuilder.field,
Op: ruleBuilder.op,
Value: ruleBuilder.value(),
})
}
groups = append(groups, QueryGroup{
Match: normalizeCatalogMatch(builder.match),
Rules: rules,
})
}
return groups, nil
}
func ensureCatalogGroupBuilder(groups map[int]*catalogGroupBuilder, groupIdx int) *catalogGroupBuilder {
builder := groups[groupIdx]
if builder == nil {
builder = &catalogGroupBuilder{rules: map[int]*catalogRuleBuilder{}}
groups[groupIdx] = builder
}
return builder
}
func ensureCatalogRuleBuilder(groups map[int]*catalogGroupBuilder, groupIdx, ruleIdx int) *catalogRuleBuilder {
group := ensureCatalogGroupBuilder(groups, groupIdx)
rule := group.rules[ruleIdx]
if rule == nil {
rule = &catalogRuleBuilder{}
group.rules[ruleIdx] = rule
}
return rule
}
func (r *catalogRuleBuilder) value() any {
if len(r.indexedValues) > 0 {
indexes := make([]int, 0, len(r.indexedValues))
for idx := range r.indexedValues {
indexes = append(indexes, idx)
}
slices.Sort(indexes)
values := make([]any, 0, len(indexes))
for _, idx := range indexes {
values = append(values, r.indexedValues[idx])
}
return values
}
if len(r.values) == 1 {
return r.values[0]
}
if len(r.values) > 1 {
return r.values
}
return nil
}
func hasCatalogOverlayParams(values url.Values, ignoreLibraryID bool) bool {
for key, rawValues := range values {
if len(rawValues) == 0 {
continue
}
if strings.HasPrefix(key, "groups[") {
return true
}
switch key {
case "source", "scope", "section_id", "collection_id", "title", "limit", "offset", "snapshot":
continue
case "library_id":
if ignoreLibraryID {
continue
}
return true
case "q", "name_prefix", "match", "type", "genre", "year_min", "year_max", "content_rating", "status", "sort", "order", "query_limit":
return true
}
}
return false
}
func normalizeCatalogMatch(raw string) string {
if strings.EqualFold(strings.TrimSpace(raw), "any") {
return "any"
}
return "all"
}
func parseCatalogMediaScope(raw string) string {
scope := strings.ToLower(strings.TrimSpace(raw))
if scope != "" && IsValidMediaScope(scope) {
return scope
}
return ""
}
func defaultCatalogQuerySort(searchQuery string) QuerySort {
if strings.TrimSpace(searchQuery) != "" {
return QuerySort{Field: "relevance", Order: "desc"}
}
return QuerySort{Field: "title", Order: "asc"}
}
func normalizeExplicitCatalogSort(field, order string) QuerySort {
normalizedField := strings.ToLower(strings.TrimSpace(field))
normalizedOrder := strings.ToLower(strings.TrimSpace(order))
if normalizedField == "relevance" {
if normalizedOrder == "" {
normalizedOrder = "desc"
}
return QuerySort{Field: normalizedField, Order: normalizedOrder}
}
normalized := NormalizeQuerySort(QuerySort{Field: normalizedField, Order: normalizedOrder})
if normalizedField == "" {
normalized.Field = ""
}
return normalized
}
func parseCatalogScalar(raw string) any {
trimmed := strings.TrimSpace(raw)
switch strings.ToLower(trimmed) {
case "true":
return true
case "false":
return false
}
if i, err := strconv.Atoi(trimmed); err == nil {
return i
}
if f, err := strconv.ParseFloat(trimmed, 64); err == nil {
return f
}
return trimmed
}