feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint

Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.

- Nine new filter params on SearchMediaItemsUnified: story_arc,
  imprint, edition (scalar-fuzzy), age_rating, format_group,
  manga_type, reading_direction (exact), explicit, abridged
  (presence-bound bools). Comma = OR-within multi-select on every
  text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
  genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
  pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
  > empty (unknown-only stays tags-only). Type-words (Manga, Comics,
  Graphic Novel) join the demote list. Both 'adventure' and 'action &
  adventure' are shelves — provider data uses both and the verified
  traces pin them.
- Language filter UX: comma elements normalize through the ingest
  language map, so display names ('English') match rows stored as ISO
  codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
  driven value+count listing (14 static per-column queries, family-lens
  join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
  403 on the search endpoints (was a silent empty set) — JSON branch
  and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
  search JSON endpoint and the HTMX grid partial; the HTML branch
  previously dropped publisher_filter and narrator_filter silently.

Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
This commit is contained in:
John O'Keefe committed 2026-10-19 18:59:23 -04:00
1 parent 7537063cb7
commit 7e586df48a
13 files changed
+2297 -283

No files matched your search

+233
View File
@@ -718,6 +718,31 @@ WHERE COALESCE(lv.is_visible, true) = true
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
))
-- Facet registry Batch A additions (services/facet_registry.go is the
-- Go-side source of truth for these params). Comma = OR-within for
-- every text facet; scalar-fuzzy uses word_similarity per value,
-- exact enums use ANY(), bools are presence-bound (NULL = unset).
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
))
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
))
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
SELECT 1
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
))
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
-- Year range (exact) - prioritize date_published, fallback to copyright_year
AND (
sqlc.narg('year_min') = 0 OR
@@ -944,6 +969,214 @@ GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.lan
ORDER BY score DESC, count DESC
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- Facet-value listings (faceted-navigation Batch C): top values + counts
-- for the filter panel, one static query per media_items column (same
-- sqlc-compatibility constraint as the Search*Values autocomplete above).
-- Visibility: same family-lens join as the search query — a user's
-- hidden-library rows are absent, and the handler 403s an out-of-set
-- explicit library_id. Counts are v1 global-per-library (not
-- recomputed under the other active filters).
-- name: FacetAuthorsValues :many
SELECT a::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.authors) AS a
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND a IS NOT NULL AND a != ''
GROUP BY a
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetPublishersValues :many
SELECT p::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.publishers) AS p
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND p IS NOT NULL AND p != ''
GROUP BY p
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetNarratorsValues :many
SELECT n::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.narrators) AS n
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND n IS NOT NULL AND n != ''
GROUP BY n
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetTagsValues :many
SELECT t::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
CROSS JOIN LATERAL unnest(mi.tags) AS t
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND t IS NOT NULL AND t != ''
GROUP BY t
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetSeriesValues :many
SELECT mi.series::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.series IS NOT NULL AND mi.series != ''
GROUP BY mi.series
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetGenreValues :many
SELECT mi.genre::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.genre IS NOT NULL AND mi.genre != ''
GROUP BY mi.genre
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetLanguageValues :many
SELECT mi.language::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.language IS NOT NULL AND mi.language != ''
GROUP BY mi.language
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetStoryArcValues :many
SELECT mi.story_arc::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
GROUP BY mi.story_arc
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetImprintValues :many
SELECT mi.imprint::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.imprint IS NOT NULL AND mi.imprint != ''
GROUP BY mi.imprint
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetEditionValues :many
SELECT mi.edition::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.edition IS NOT NULL AND mi.edition != ''
GROUP BY mi.edition
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetAgeRatingValues :many
SELECT mi.age_rating::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
GROUP BY mi.age_rating
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetFormatGroupValues :many
SELECT mi.format_group::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.format_group IS NOT NULL AND mi.format_group != ''
GROUP BY mi.format_group
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetMangaTypeValues :many
SELECT mi.manga_type::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown'
GROUP BY mi.manga_type
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- name: FacetReadingDirectionValues :many
SELECT mi.reading_direction::text AS value, COUNT(*) AS count
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 = sqlc.narg('user_id')
WHERE COALESCE(lv.is_visible, true) = true
AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id')
AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
GROUP BY mi.reading_direction
ORDER BY count DESC, value ASC
LIMIT sqlc.narg('limit');
-- Media Notes queries
-- name: GetMediaNote :one
SELECT * FROM media_notes WHERE id = $1;