Files
silo-server/internal/jellycompat/handlers_persons.go
CoffeeKnyteandGitHub 1dbcf2cd9a fix(jellycompat): guard aux search paths and index-back person search (#252)
* fix(jellycompat): guard aux search paths and index-back person search

Short, recursive type-ahead terms (e.g. a single "a") against the
PostgreSQL people index and the in-memory collection/box-set filter
produced multi-second scans that pegged the server when a client fired
one search per keystroke. Every jellycompat search path except the
Meilisearch-backed /Items media search now rejects a provided SearchTerm
shorter than 3 runes without touching any backend, and caps results at
20 regardless of the client-requested Limit:
- /Persons        (PostgreSQL people scan)
- /Search/Hints   (catalog search)
- /Items BoxSet   (in-memory collection filter)

Shared policy + helpers live in search_guard.go (auxSearchMinTermLen=2,
i.e. reject 1-2 runes / allow 3+, and auxSearchMaxResults=20) with unit
coverage. The 3-rune floor is deliberate: 3 runes is the point where a
pg_trgm trigram index becomes usable, so the gate lines up with the index
and still lets legitimate short titles/names ("300", "Saw", 3-letter
actors) and 3-char type-ahead hints through.

PersonRepository.Search filters with `name ILIKE '%'||$1||'%'` rather
than `LOWER(name) LIKE '%'||LOWER($1)||'%'`. The old expression filtered
on LOWER(name), which the trigram GIN index idx_people_name_trgm (built
on name) could not serve, so every search fell back to an ordered index
scan on idx_people_name that walked the whole table for rare terms
(~300-400ms on the 889k-row production people table). ILIKE on name lets
pg_trgm serve rare 3+ char terms from the trigram index, while the planner
still picks the ordered btree scan with early termination for common
terms. ILIKE is case-insensitive, so behavior is preserved (including the
pre-existing treatment of % and _ in the term as LIKE wildcards).

Verified on production silo-postgres (889,302 people), parameterized query
under both custom and generic plan cache modes:
  rare 3-char 'qzx':  304ms -> 1-8ms   (trgm bitmap index)
  rare 4-char 'zzzz': 329ms -> 1-2ms   (trgm bitmap index)
  common 3-char 'ann':  15ms -> 7-74ms (btree early-stop / trgm bitmap)
New worst case across all terms is ~83ms (generic-plan common 3-char).

* fix(jellycompat): stop gating meilisearch hints, clamp box-set search

Two review follow-ups on the aux-search guards:

- HandleSearchHints is served by the catalog (Meilisearch-backed) search
  provider, which already bounds and short-term-handles its own results.
  Gating it with auxSearchTermTooShort contradicted the guard's own
  "non-Meilisearch paths only" policy and hid valid 1-2 char titles
  ("Up", "It") from global type-ahead. Drop the too-short gate there;
  keep the empty-term check and the result clamp.

- handleBoxSetsList gated short terms but passed query.limit straight to
  slicePage, which treats <=0 as no cap, so a box-set *search* was
  uncapped unlike Persons/Hints. Clamp the limit when a search term is
  present; empty-term browse keeps its client paging window.
2026-07-01 09:05:41 -04:00

206 lines
5.5 KiB
Go

package jellycompat
import (
"context"
"errors"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// PersonsHandler serves the Jellyfin /Persons endpoints.
type PersonsHandler struct {
personRepo *catalog.PersonRepository
content ContentService
codec *ResourceIDCodec
images *ImageCache
serverID string
}
// NewPersonsHandler creates a new persons handler.
func NewPersonsHandler(personRepo *catalog.PersonRepository, content ContentService, codec *ResourceIDCodec, images *ImageCache, serverID string) *PersonsHandler {
return &PersonsHandler{
personRepo: personRepo,
content: content,
codec: codec,
images: images,
serverID: serverID,
}
}
// HandleGetPersons serves GET /Persons.
func (h *PersonsHandler) HandleGetPersons(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
q := newCaseInsensitiveQuery(r.URL.Query())
searchTerm := strings.TrimSpace(q.Get("SearchTerm"))
// Person search hits PostgreSQL directly (it is not in the Meilisearch
// index), so it is gated: short terms never run and results are capped.
limit := clampAuxSearchLimit(parsePositiveInt(q.Get("Limit"), auxSearchMaxResults))
var people []models.Person
var err error
if searchTerm != "" {
if auxSearchTermTooShort(searchTerm) {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
})
return
}
if h.shouldSuppressSearchPeople(r.Context(), session, searchTerm) {
writeJSON(w, http.StatusOK, queryResultDTO{
Items: []baseItemDTO{},
TotalRecordCount: 0,
})
return
}
people, err = h.personRepo.Search(r.Context(), searchTerm, limit)
} else {
people, err = h.personRepo.Search(r.Context(), "", limit)
}
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
items := make([]baseItemDTO, 0, len(people))
for _, p := range people {
items = append(items, h.personToDTO(p))
}
writeJSON(w, http.StatusOK, queryResultDTO{
Items: items,
TotalRecordCount: len(items),
})
}
// HandleGetPerson serves GET /Persons/{name}.
func (h *PersonsHandler) HandleGetPerson(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
name := chi.URLParam(r, "name")
person, err := h.personRepo.GetByName(r.Context(), name)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "NotFound", "Person not found")
} else {
writeError(w, http.StatusInternalServerError, "InternalError", err.Error())
}
return
}
writeJSON(w, http.StatusOK, h.personToDTO(*person))
}
func (h *PersonsHandler) personToDTO(p models.Person) baseItemDTO {
dto := baseItemDTO{
ID: h.codec.EncodeIntID(EncodedIDPerson, p.ID),
Name: p.Name,
Type: "Person",
ServerID: h.serverID,
}
if p.PhotoPath != "" && p.PhotoPath != "-" {
dto.ImageTags = map[string]string{"Primary": tagValue(p.PhotoPath)}
}
return dto
}
// personNamePattern matches strings that "look like" a person name: a leading
// Unicode letter followed by additional letters or common name punctuation
// (whitespace, dot, hyphen, apostrophe). Digits and colons disqualify the
// candidate, sending it through the slower media-probe path used to detect
// when a query is actually a media title.
var personNamePattern = regexp.MustCompile(`^[\p{L}][\p{L}\s.\-']{1,}$`)
// looksLikePersonName returns true when the search term has a "name shape":
// word boundaries with letters / common name punctuation only, no digits,
// no colons. Avoids the round-trip media search for the common case.
func looksLikePersonName(term string) bool {
term = strings.TrimSpace(term)
if len(term) < 2 {
return false
}
return personNamePattern.MatchString(term)
}
func (h *PersonsHandler) shouldSuppressSearchPeople(ctx context.Context, session *Session, raw string) bool {
if h == nil || h.content == nil || session == nil {
return false
}
// If the term clearly looks like a person name, skip the media probe
// entirely and let the persons search proceed.
if looksLikePersonName(raw) {
return false
}
query := normalizeCompatSearchValue(raw)
if query == "" {
return false
}
media, err := h.content.SearchItems(ctx, session, SearchItemsOptions{
Query: raw,
ItemTypes: []string{"movie", "series"},
Limit: 5,
SkipTotal: true,
})
if err != nil || media == nil || len(media.Items) == 0 {
return false
}
exactMatch := false
prefixMatches := 0
for _, item := range media.Items {
title := normalizeCompatSearchValue(item.Title)
if title == "" {
continue
}
if title == query {
exactMatch = true
break
}
if strings.HasPrefix(title, query) {
prefixMatches++
}
}
return exactMatch || prefixMatches >= 2
}
func normalizeCompatSearchValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
default:
b.WriteByte(' ')
}
}
return strings.Join(strings.Fields(b.String()), " ")
}