feat: add fuzzy tags_filter to search query

- Add tags_filter parameter to SearchMediaItemsUnified
- Add EXISTS clause with word_similarity() for fuzzy tag matching
- Add tag similarity scoring to ORDER BY clause (GREATEST function)
- Add SearchTagsValues query for autocomplete with ::TEXT cast
- Keep genre_filter for backward compatibility
- Regenerate Go code with sqlc generate

This enables filtering books by tags (from Calibre) instead of genre,
which is always NULL for imported books. Uses fuzzy matching consistent
with author/series filters, with best matches sorted first.

Relates to IMPLEMENTATION_TAGS_FILTER.md Phase 1
This commit is contained in:
2026-03-25 20:38:08 -04:00
parent a643bd43b8
commit 00840c2fe1
3 changed files with 122 additions and 32 deletions
+97 -31
View File
@@ -7254,92 +7254,99 @@ WHERE COALESCE(lv.is_visible, true) = true
AND ($5 = '' OR word_similarity($5, COALESCE(mi.genre, '')) > 0.3)
-- Fuzzy language filter
AND ($6 = '' OR word_similarity($6, COALESCE(mi.language, '')) > 0.3)
-- Tags filter (NEW - fuzzy match against tags array)
AND ($7 = '' OR EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($7, tag) > 0.3
))
-- Year range (exact)
AND ($7 = 0 OR mi.copyright_year >= $7)
AND ($8 = 0 OR mi.copyright_year <= $8)
AND ($8 = 0 OR mi.copyright_year >= $8)
AND ($9 = 0 OR mi.copyright_year <= $9)
-- Boolean (exact)
AND ($9 = false OR mi.cover_image_path IS NOT NULL)
AND ($10 = false OR mi.cover_image_path IS NOT NULL)
-- Search query (fuzzy or exact based on quotes)
AND (
$10 = '' OR
$11 = '' OR
-- Fuzzy search (default)
$11 = false AND (
word_similarity($10, mi.title) > 0.3 OR
word_similarity($10, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($10, COALESCE(mi.series, '')) > 0.3 OR
$12 = false AND (
word_similarity($11, mi.title) > 0.3 OR
word_similarity($11, COALESCE(mi.author, '')) > 0.3 OR
word_similarity($11, COALESCE(mi.series, '')) > 0.3 OR
EXISTS (
SELECT 1 FROM unnest(mi.tags_search) AS tag
WHERE word_similarity($10, tag) > 0.3
WHERE word_similarity($11, tag) > 0.3
LIMIT 1
) OR
EXISTS (
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
WHERE word_similarity($10, contributor) > 0.3
WHERE word_similarity($11, contributor) > 0.3
LIMIT 1
)
) OR
-- Exact search (with quotes)
$11 = true AND (
mi.title ILIKE $12 OR
mi.author ILIKE $12 OR
mi.series ILIKE $12 OR
$12 = ANY(mi.tags_search) OR
$12 = ANY(mi.contributors_search)
$12 = true AND (
mi.title ILIKE $13 OR
mi.author ILIKE $13 OR
mi.series ILIKE $13 OR
$13 = ANY(mi.tags_search) OR
$13 = ANY(mi.contributors_search)
)
)
ORDER BY
mi.id,
-- Primary sort: relevance score when searching
CASE
WHEN $10 != '' THEN
WHEN $11 != '' THEN
GREATEST(
CASE WHEN $11 = false THEN
word_similarity($10, mi.title)
CASE WHEN $12 = false THEN
word_similarity($11, mi.title)
ELSE 0 END,
CASE WHEN $11 = false THEN
word_similarity($10, COALESCE(mi.author, ''))
CASE WHEN $12 = false THEN
word_similarity($11, COALESCE(mi.author, ''))
ELSE 0 END,
word_similarity($3, COALESCE(mi.author, '')),
word_similarity($5, COALESCE(mi.genre, ''))
word_similarity($5, COALESCE(mi.genre, '')),
(SELECT MAX(word_similarity($7, tag))
FROM unnest(mi.tags_search) AS tag)
)
ELSE 0
END DESC,
-- Secondary sort: user-specified sort parameter
CASE
WHEN $13 = 'title ASC' THEN mi.title
WHEN $14 = 'title ASC' THEN mi.title
ELSE ''
END ASC,
CASE
WHEN $13 = 'title DESC' THEN mi.title
WHEN $14 = 'title DESC' THEN mi.title
ELSE ''
END DESC,
CASE
WHEN $13 = 'author ASC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author ASC' THEN COALESCE(mi.author, '')
ELSE ''
END ASC,
CASE
WHEN $13 = 'author DESC' THEN COALESCE(mi.author, '')
WHEN $14 = 'author DESC' THEN COALESCE(mi.author, '')
ELSE ''
END DESC,
CASE
WHEN $13 = 'created_at ASC' THEN mi.created_at
WHEN $14 = 'created_at ASC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END ASC,
CASE
WHEN $13 = 'created_at DESC' THEN mi.created_at
WHEN $14 = 'created_at DESC' THEN mi.created_at
ELSE '1970-01-01'::timestamp
END DESC,
CASE
WHEN $13 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END ASC,
CASE
WHEN $13 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
WHEN $14 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
ELSE ''
END DESC,
-- Tertiary sort: title (default fallback)
mi.title ASC
LIMIT $15 OFFSET $14
LIMIT $16 OFFSET $15
`
type SearchMediaItemsUnifiedParams struct {
@@ -7349,6 +7356,7 @@ type SearchMediaItemsUnifiedParams struct {
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
YearMin interface{} `db:"year_min" json:"year_min"`
YearMax interface{} `db:"year_max" json:"year_max"`
HasCover interface{} `db:"has_cover" json:"has_cover"`
@@ -7417,6 +7425,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
arg.SeriesFilter,
arg.GenreFilter,
arg.LanguageFilter,
arg.TagsFilter,
arg.YearMin,
arg.YearMax,
arg.HasCover,
@@ -7550,6 +7559,63 @@ func (q *Queries) SearchSeriesValues(ctx context.Context, arg SearchSeriesValues
return items, nil
}
const SearchTagsValues = `-- name: SearchTagsValues :many
SELECT
unnest(mi.tags_search)::TEXT as value,
COUNT(*) as count,
word_similarity($1, unnest(mi.tags_search))::float8 as score
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
WHERE COALESCE(lv.is_visible, true) = true
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
AND unnest(mi.tags_search) IS NOT NULL
AND word_similarity($1, unnest(mi.tags_search)) > 0.3
GROUP BY unnest(mi.tags_search), word_similarity($1, unnest(mi.tags_search))::float8
ORDER BY word_similarity($1, unnest(mi.tags_search))::float8 DESC, COUNT DESC
LIMIT $5 OFFSET $4
`
type SearchTagsValuesParams struct {
SearchQuery pgtype.Text `db:"search_query" json:"search_query"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Offset pgtype.Int4 `db:"offset" json:"offset"`
Limit pgtype.Int4 `db:"limit" json:"limit"`
}
type SearchTagsValuesRow struct {
Value string `db:"value" json:"value"`
Count int64 `db:"count" json:"count"`
Score float64 `db:"score" json:"score"`
}
func (q *Queries) SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) {
rows, err := q.db.Query(ctx, SearchTagsValues,
arg.SearchQuery,
arg.UserID,
arg.LibraryID,
arg.Offset,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SearchTagsValuesRow{}
for rows.Next() {
var i SearchTagsValuesRow
if err := rows.Scan(&i.Value, &i.Count, &i.Score); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const SetLibraryVisibility = `-- name: SetLibraryVisibility :one
INSERT INTO library_visibility (user_id, library_id, is_visible)
VALUES ($1, $2, $3)