Files
silo-server/internal/catalog/search_postgres_mixed.go
383973ec22 feat(metadata): improve match accuracy and localized titles (#461)
* feat(metadata): improve match accuracy and localized titles

* fix(metadata): address matching review findings

* test(catalog): align empty alias snapshot scope

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:02:52 -04:00

401 lines
15 KiB
Go

package catalog
import (
"context"
"fmt"
"strings"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
// GetSearchItemsByIDsWithAccess hydrates mixed search hits in one round trip.
// Meilisearch IDs are only candidates: both branches reapply the effective
// access filter, and callers restore provider order after this method returns.
func (r *ItemRepository) GetSearchItemsByIDsWithAccess(
ctx context.Context,
contentIDs []string,
filter AccessFilter,
) ([]*models.MediaItem, error) {
if len(contentIDs) == 0 || (filter.AllowedLibraryIDs != nil && len(filter.AllowedLibraryIDs) == 0) {
return []*models.MediaItem{}, nil
}
args := []any{contentIDs}
argIdx := 2
mediaConditions := []string{"hydrated_mi.content_id = ANY($1)"}
appendLibraryAccessConditions("hydrated_mi.content_id", filter, &mediaConditions, &args, &argIdx)
applyAccessFilter("hydrated_mi", AccessFilter{
MaxContentRating: filter.MaxContentRating,
ExcludedMediaTypes: filter.ExcludedMediaTypes,
}, &mediaConditions, &args, &argIdx)
episodeConditions := []string{"mi.content_id = ANY($1)"}
appendEpisodeLibrarySearchAccess("mi.content_id", filter, &episodeConditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{
MaxContentRating: filter.MaxContentRating,
ExcludedMediaTypes: filter.ExcludedMediaTypes,
}, &episodeConditions, &args, &argIdx)
query := fmt.Sprintf(`
SELECT %s
FROM media_items hydrated_mi
WHERE %s
UNION ALL
SELECT %s
FROM %s
WHERE %s`,
qualifiedItemColumns("hydrated_mi"), strings.Join(mediaConditions, " AND "),
qualifiedItemColumns("mi"), episodeCatalogBaseRelation, strings.Join(episodeConditions, " AND "))
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("hydrating mixed search items: %w", err)
}
defer rows.Close()
return scanItems(rows)
}
const episodeSearchTitleExpr = `COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text)`
const episodeSearchTitleVector = `setweight(
to_tsvector('simple', public.normalize_search_text(COALESCE(NULLIF(BTRIM(e.title), ''), 'Episode ' || e.episode_number::text))),
'A'
)`
const episodeSearchOverviewVector = `to_tsvector('english', COALESCE(e.overview, ''))`
const mixedSearchOrder = `exact_title_match DESC, contiguous_title_match DESC, year_match DESC,
phrase_rank DESC, title_rank DESC, title_prefix_rank DESC, overview_rank DESC,
LOWER(title) ASC, content_id ASC`
// buildMixedSearchSQLFromParsed builds one ranked candidate set from the two
// physical catalog sources. The scored CTE deliberately carries only ranking
// fields; the wide MediaItem projection is hydrated after LIMIT/OFFSET so a
// broad match never sorts posters, arrays, or metadata blobs for every hit.
func (r *ItemRepository) buildMixedSearchSQLFromParsed(
parsed parsedSearchQuery,
itemTypes []string,
limit, offset int,
filter AccessFilter,
includeTotal bool,
) (dataSQL, countSQL string, args []any) {
searchText := searchTextFromParsed(parsed)
if searchText == "" {
return "", "", nil
}
mediaTypes, includeEpisodes := splitSearchItemTypes(itemTypes)
includeMediaItems := len(itemTypes) == 0 || len(mediaTypes) > 0
if !includeMediaItems && !includeEpisodes {
return "", "", nil
}
args = []any{searchText, buildTitlePrefixTsQuery(searchText)}
argIdx := 3
var branches []string
mediaConditions := []string{}
if includeMediaItems {
mediaTitleVector := `(
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.title, ''))), 'A') ||
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.original_title, ''))), 'A') ||
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.sort_title, ''))), 'B')
)`
mediaOverviewVector := `to_tsvector('english', COALESCE(mi.overview, ''))`
mediaConditions = append(mediaConditions, searchMatchCondition(mediaTitleVector, mediaOverviewVector, mediaSearchAliasMatchArms()...))
if len(itemTypes) > 0 {
mediaConditions = append(mediaConditions, fmt.Sprintf("mi.type = ANY($%d)", argIdx))
args = append(args, mediaTypes)
argIdx++
}
appendLibraryAccessConditions("mi.content_id", filter, &mediaConditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{
MaxContentRating: filter.MaxContentRating,
ExcludedMediaTypes: filter.ExcludedMediaTypes,
}, &mediaConditions, &args, &argIdx)
mediaConditions = append(mediaConditions, MangaChapterExclusionWhere("mi"))
}
episodeConditions := []string{}
if includeEpisodes {
episodeConditions = append(episodeConditions,
"si.type = 'series'",
`EXISTS (SELECT 1 FROM episode_libraries available_el WHERE available_el.episode_id = e.content_id)`,
searchMatchCondition(episodeSearchTitleVector, episodeSearchOverviewVector),
)
appendEpisodeLibrarySearchAccess("e.content_id", filter, &episodeConditions, &args, &argIdx)
if filter.MaxContentRating != "" {
allowedRatings := access.AllowedRatingsUpTo(filter.MaxContentRating)
if len(allowedRatings) == 0 {
episodeConditions = append(episodeConditions, "1 = 0")
} else {
episodeConditions = append(episodeConditions, fmt.Sprintf("si.content_rating = ANY($%d)", argIdx))
args = append(args, allowedRatings)
argIdx++
}
}
if len(filter.ExcludedMediaTypes) > 0 {
episodeConditions = append(episodeConditions, fmt.Sprintf("NOT ('episode' = ANY($%d))", argIdx))
args = append(args, filter.ExcludedMediaTypes)
argIdx++
}
}
exactIdx := argIdx
args = append(args, parsed.ExactTitleHint)
argIdx++
var yearArg any
if parsed.Year != nil {
yearArg = *parsed.Year
}
yearIdx := argIdx
args = append(args, yearArg)
argIdx++
phraseIdx := argIdx
args = append(args, parsed.Phrase)
argIdx++
if includeMediaItems {
mediaAliasArms := mediaSearchAliasArms(exactIdx)
branches = append(branches, buildMixedSearchCandidateBranch(
"mi.content_id", "mi.type", "mi.title", "mi.year",
`(
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.title, ''))), 'A') ||
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.original_title, ''))), 'A') ||
setweight(to_tsvector('simple', public.normalize_search_text(COALESCE(mi.sort_title, ''))), 'B')
)`,
`to_tsvector('english', COALESCE(mi.overview, ''))`,
[]string{`mi.title_normalized`, `public.normalize_search_text(mi.original_title)`, `public.normalize_search_text(mi.sort_title)`},
"media_items mi", mediaConditions, exactIdx, yearIdx, phraseIdx,
&mediaAliasArms,
))
}
if includeEpisodes {
episodeNormalizedTitle := `public.normalize_search_text(` + episodeSearchTitleExpr + `)`
branches = append(branches, buildMixedSearchCandidateBranch(
"e.content_id", "'episode'::text", episodeSearchTitleExpr,
"COALESCE(si.year, EXTRACT(YEAR FROM e.air_date)::integer, 0)",
episodeSearchTitleVector, episodeSearchOverviewVector,
[]string{episodeNormalizedTitle},
"episodes e JOIN media_items si ON si.content_id = e.series_id",
episodeConditions, exactIdx, yearIdx, phraseIdx,
nil,
))
}
scoredCTE := "WITH scored AS (\n" + strings.Join(branches, "\nUNION ALL\n") + "\n)"
statsCTE := `, stats AS (
SELECT MAX(CASE WHEN title_rank > 0 OR title_prefix_rank > 0 THEN 1 ELSE 0 END) AS has_title_match
FROM scored
)`
postFilter := fmt.Sprintf(`FROM scored
CROSS JOIN stats
WHERE scored.title_rank > 0
OR scored.title_prefix_rank > 0
OR (COALESCE(stats.has_title_match, 0) = 0 AND scored.overview_rank >= %g)`, overviewMatchFloor)
pageTotalColumn := ""
finalTotalColumn := ""
if includeTotal {
pageTotalColumn = ", COUNT(*) OVER () AS total_count"
finalTotalColumn = ", page.total_count"
}
limitIdx, offsetIdx := argIdx, argIdx+1
args = append(args, limit, offset)
pageCTE := fmt.Sprintf(`, page AS (
SELECT scored.*, ROW_NUMBER() OVER (ORDER BY %s) AS ordinal%s
%s
ORDER BY %s
LIMIT $%d OFFSET $%d
)`, mixedSearchOrder, pageTotalColumn, postFilter, mixedSearchOrder, limitIdx, offsetIdx)
hydratedRelation := fmt.Sprintf(`LATERAL (
SELECT %s
FROM media_items hydrated_mi
WHERE page.type <> 'episode'
AND hydrated_mi.content_id = page.content_id
UNION ALL
SELECT %s
FROM %s
WHERE page.type = 'episode'
AND mi.content_id = page.content_id
) hydrated`, qualifiedItemColumns("hydrated_mi"), qualifiedItemColumns("mi"), episodeCatalogBaseRelation)
dataSQL = scoredCTE + statsCTE + pageCTE + fmt.Sprintf(`
SELECT %s%s
FROM page
JOIN %s ON true
ORDER BY page.ordinal`, qualifiedItemColumns("hydrated"), finalTotalColumn, hydratedRelation)
countSQL = scoredCTE + statsCTE + fmt.Sprintf("\nSELECT COUNT(*)\n%s", postFilter)
return dataSQL, countSQL, args
}
func splitSearchItemTypes(itemTypes []string) (mediaTypes []string, includeEpisodes bool) {
if len(itemTypes) == 0 {
return nil, true
}
seen := make(map[string]struct{}, len(itemTypes))
for _, itemType := range itemTypes {
itemType = strings.ToLower(strings.TrimSpace(itemType))
if itemType == "" {
continue
}
if itemType == "episode" {
includeEpisodes = true
continue
}
if _, ok := seen[itemType]; ok {
continue
}
seen[itemType] = struct{}{}
mediaTypes = append(mediaTypes, itemType)
}
return mediaTypes, includeEpisodes
}
func searchMatchCondition(titleVector, overviewVector string, extraArms ...string) string {
titleQuery := `websearch_to_tsquery('simple', public.normalize_search_text($1))`
prefixQuery := `to_tsquery('simple', $2)`
arms := []string{
fmt.Sprintf(`(%s) @@ %s`, titleVector, titleQuery),
fmt.Sprintf(`($2 <> '' AND (%s) @@ %s)`, titleVector, prefixQuery),
fmt.Sprintf(`(%s) @@ websearch_to_tsquery('english', $1)`, overviewVector),
}
arms = append(arms, extraArms...)
return "(" + strings.Join(arms, " OR ") + ")"
}
// mediaSearchAliasMatchArms are the alias arms OR'd into the media branch's
// match condition. They are scalar array subqueries (InitPlans), NOT `IN
// (subquery)` arms: an uncorrelated subquery result is a plain parameter, so
// `content_id = ANY(...)` stays index-served and participates in the BitmapOr
// with the title/overview GIN arms. An `IN (SELECT ...)` arm inside an OR has
// no index path and forces a seq scan of media_items that rebuilds every
// tsvector per row.
func mediaSearchAliasMatchArms() []string {
return []string{
`mi.content_id = ANY(COALESCE((
SELECT array_agg(DISTINCT mia.content_id) FROM media_item_aliases mia
WHERE to_tsvector('simple', mia.normalized_title) @@ websearch_to_tsquery('simple', public.normalize_search_text($1))
), '{}'::text[]))`,
`($2 <> '' AND mi.content_id = ANY(COALESCE((
SELECT array_agg(DISTINCT mia.content_id) FROM media_item_aliases mia
WHERE to_tsvector('simple', mia.normalized_title) @@ to_tsquery('simple', $2)
), '{}'::text[])))`,
}
}
// mixedSearchAliasArms carries the media branch's provider-alias extensions to
// the per-branch ranking SELECT. Episodes have no aliases and pass nil.
type mixedSearchAliasArms struct {
exactArm string // OR'd into exact_title_match
contiguousArm string // OR'd into contiguous_title_match
titleRank string // GREATEST'd with title_rank
prefixRank string // GREATEST'd with title_prefix_rank
}
// mediaSearchAliasArms builds the alias ranking arms for the media branch.
// The correlated rank subqueries run per matched row only (content_id
// lookups), and setweight(..., 'A') makes an alias hit rank like a real title
// hit — an unweighted tsvector defaults to weight D (0.1), which buried exact
// alias matches ~10x below partial real-title matches. The WHERE arms above
// stay unweighted to keep matching idx_media_item_aliases_search_vector's
// indexed expression.
func mediaSearchAliasArms(exactIdx int) mixedSearchAliasArms {
return mixedSearchAliasArms{
exactArm: fmt.Sprintf(`mi.content_id IN (
SELECT mia.content_id FROM media_item_aliases mia
WHERE mia.normalized_title = $%d
)`, exactIdx),
contiguousArm: fmt.Sprintf(`mi.content_id IN (
SELECT mia.content_id FROM media_item_aliases mia
WHERE mia.normalized_title LIKE '%%' || $%d || '%%'
)`, exactIdx),
titleRank: `COALESCE((
SELECT MAX(ts_rank_cd(setweight(to_tsvector('simple', mia.normalized_title), 'A'), websearch_to_tsquery('simple', public.normalize_search_text($1))))
FROM media_item_aliases mia WHERE mia.content_id = mi.content_id
), 0)`,
prefixRank: `COALESCE((
SELECT MAX(ts_rank_cd(setweight(to_tsvector('simple', mia.normalized_title), 'A'), to_tsquery('simple', $2)))
FROM media_item_aliases mia WHERE mia.content_id = mi.content_id
), 0)`,
}
}
func buildMixedSearchCandidateBranch(
contentIDExpr, typeExpr, titleExpr, yearExpr, titleVector, overviewVector string,
exactTitleExprs []string,
fromClause string,
conditions []string,
exactIdx, yearIdx, phraseIdx int,
aliasArms *mixedSearchAliasArms,
) string {
exactArms := make([]string, 0, len(exactTitleExprs)+1)
contiguousArms := make([]string, 0, len(exactTitleExprs)+1)
for _, expr := range exactTitleExprs {
exactArms = append(exactArms, fmt.Sprintf("%s = $%d", expr, exactIdx))
contiguousArms = append(contiguousArms, fmt.Sprintf("%s LIKE '%%' || $%d || '%%'", expr, exactIdx))
}
titleQuery := `websearch_to_tsquery('simple', public.normalize_search_text($1))`
prefixQuery := `to_tsquery('simple', $2)`
titleRankExpr := fmt.Sprintf("ts_rank_cd(%s, %s)", titleVector, titleQuery)
prefixRankExpr := fmt.Sprintf("ts_rank_cd(%s, %s)", titleVector, prefixQuery)
if aliasArms != nil {
// Alias exact/contiguous arms are hashed subplans in the CASE select
// list (evaluated per grouped row, hash built once); the rank arms are
// per-row correlated content_id lookups over the matched set only.
exactArms = append(exactArms, aliasArms.exactArm)
contiguousArms = append(contiguousArms, aliasArms.contiguousArm)
titleRankExpr = fmt.Sprintf("GREATEST(%s, %s)", titleRankExpr, aliasArms.titleRank)
prefixRankExpr = fmt.Sprintf("GREATEST(%s, %s)", prefixRankExpr, aliasArms.prefixRank)
}
return fmt.Sprintf(`
SELECT
%s AS content_id,
%s AS type,
%s AS title,
CASE WHEN $%d <> '' AND (%s) THEN 1 ELSE 0 END AS exact_title_match,
CASE WHEN $%d <> '' AND (%s) THEN 1 ELSE 0 END AS contiguous_title_match,
CASE WHEN $%d::int IS NOT NULL AND (%s) = $%d::int THEN 1 ELSE 0 END AS year_match,
%s AS title_rank,
CASE WHEN $2 <> '' THEN %s ELSE 0 END AS title_prefix_rank,
ts_rank_cd(%s, websearch_to_tsquery('english', $1)) AS overview_rank,
CASE WHEN $%d <> '' THEN ts_rank_cd(%s, phraseto_tsquery('simple', public.normalize_search_text($%d))) ELSE 0 END AS phrase_rank
FROM %s
WHERE %s`,
contentIDExpr, typeExpr, titleExpr,
exactIdx, strings.Join(exactArms, " OR "),
exactIdx, strings.Join(contiguousArms, " OR "),
yearIdx, yearExpr, yearIdx,
titleRankExpr,
prefixRankExpr,
overviewVector,
phraseIdx, titleVector, phraseIdx,
fromClause, strings.Join(conditions, " AND "))
}
func appendEpisodeLibrarySearchAccess(
episodeIDExpr string,
filter AccessFilter,
conditions *[]string,
args *[]any,
argIdx *int,
) {
if filter.AllowedLibraryIDs != nil {
*conditions = append(*conditions, fmt.Sprintf(
"EXISTS (SELECT 1 FROM episode_libraries allowed_el WHERE allowed_el.episode_id = %s AND allowed_el.media_folder_id = ANY($%d))",
episodeIDExpr, *argIdx))
*args = append(*args, filter.AllowedLibraryIDs)
*argIdx++
}
if len(filter.DisabledLibraryIDs) > 0 {
*conditions = append(*conditions, fmt.Sprintf(
"NOT EXISTS (SELECT 1 FROM episode_libraries disabled_el WHERE disabled_el.episode_id = %s AND disabled_el.media_folder_id = ANY($%d))",
episodeIDExpr, *argIdx))
*args = append(*args, filter.DisabledLibraryIDs)
*argIdx++
}
}