Search previously mixed audiobooks into movie/series results with no way to separate them beyond single-type filters. Backend: accept a new "video" group media scope (movies + series) anywhere a media_scope is valid, expanded centrally via MediaScopeItemTypes into the search item-type list, browse comma-list Type filter, and a type = ANY(...) condition in the query executor. Register a user-scoped search.media_scope setting (all|video|audiobook, default video). Frontend: Media / Audiobooks / All chips on the search results page that filter results and persist the choice as the user's default; the global search typeahead follows the same preference. An explicit URL ?type= always wins (with type=all as an unscoped sentinel), and the filter-bar dropdown gains a Movies & Series option. The API surface is additive, so Android/Apple clients are unaffected until they adopt the new scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442 lines
12 KiB
Go
442 lines
12 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
|
|
req.UseSourceOrder = !overlay.hasExplicitSort
|
|
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")
|
|
}
|
|
if hasCatalogOverlayParams(values, false) {
|
|
return CatalogRequest{}, fmt.Errorf("source %q does not allow overlay params", req.Source)
|
|
}
|
|
req.UseSourceOrder = true
|
|
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
|
|
}
|