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

+169 -71
View File
@@ -73,7 +73,7 @@ type CreateMediaItemRequest struct {
type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
Subtitle string `form:"subtitle" json:"subtitle"`
Explicit bool `form:"explicit" json:"explicit"`
ISBN string `form:"isbn" json:"isbn"`
@@ -2349,81 +2349,33 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
}
// Handle media item search with filters
query := c.QueryParam("q")
libraryID := c.QueryParam("library_id")
// Validate library_id if provided
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
} else {
libUUID = pgtype.UUID{Valid: false}
}
params := ParseMediaSearchParams(c)
params.UserID = userID.ID
c.Logger().Info("Search parameters",
"query", query,
"library_id", libraryID,
"libUUID_valid", libUUID.Valid,
"libUUID_bytes", libUUID.Bytes)
"query", params.SearchQuery,
"library_id", libraryIDString(c),
"libUUID_valid", params.LibraryID.Valid,
"libUUID_bytes", params.LibraryID.Bytes)
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
// Extract filter parameters
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
narratorFilter := c.QueryParam("narrator_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
hasCover := false
hasCoverValid := false
if _, exists := c.QueryParams()["has_cover"]; exists {
hasCover = c.QueryParam("has_cover") == "true"
hasCoverValid = true
}
// Extract sort parameter
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC" // Default sort
}
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
NarratorFilter: narratorFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
// Family-lens gate (AGENTS.md → Library visibility): an explicit
// library_id outside the requester's visible set is a 403, never a
// silent empty set.
if params.LibraryID.Valid {
visible, err := mh.libraryVisible(c, params.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
}
c.Logger().Info("SearchMediaItems called",
"query", query,
"library_id_provided", libraryID != "",
"library_id_uuid", libUUID.Valid,
"library_id_bytes", libUUID.Bytes)
"query", params.SearchQuery,
"library_id_provided", params.LibraryID.Valid,
"library_id_uuid", params.LibraryID.Valid,
"library_id_bytes", params.LibraryID.Bytes)
// Call shared search service
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
@@ -2434,7 +2386,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
if len(results) == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "no results found",
"query": query,
"query": params.SearchQuery,
"results": []interface{}{},
})
}
@@ -2445,6 +2397,152 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
return c.JSON(http.StatusOK, results)
}
// libraryIDString returns the raw library_id query param (logging only).
func libraryIDString(c *echo.Context) string { return c.QueryParam("library_id") }
// searchParamSource abstracts the query-param surface (echo.Context)
// so the shared parser serves both handlers and router.
type searchParamSource interface {
QueryParam(name string) string
QueryParams() url.Values
}
// parseMediaSearchParams parses the unified search's query params into
// services.SearchParams — the SINGLE parse for both the JSON endpoint
// (MediaHandler.SearchMediaItems) and the HTMX grid partial
// (router handleSearchHTML), so the two branches can never disagree.
// Text facet params: comma = OR-within (the SQL splits via
// string_to_array). Bool facet params: presence-bound (absent = no
// filter, "true"/"false" = bound). Facet names come from the facet
// registry (services/facet_registry.go) — adding a facet there means
// one line here, mirroring its narg block in queries.sql.
func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC" // Default sort
}
var libUUID pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
if lib, err := uuid.Parse(libraryID); err == nil {
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
}
}
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
boolFacet := func(name string) pgtype.Bool {
if _, exists := c.QueryParams()[name]; !exists {
return pgtype.Bool{Valid: false}
}
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
}
return services.SearchParams{
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
SeriesFilter: c.QueryParam("series_filter"),
GenreFilter: c.QueryParam("genre_filter"),
PublisherFilter: c.QueryParam("publisher_filter"),
TagsFilter: c.QueryParam("tags_filter"),
NarratorFilter: c.QueryParam("narrator_filter"),
LanguageFilter: c.QueryParam("language_filter"),
YearMin: yearMin,
YearMax: yearMax,
HasCover: boolFacet("has_cover"),
StoryArcFilter: c.QueryParam("story_arc_filter"),
ImprintFilter: c.QueryParam("imprint_filter"),
EditionFilter: c.QueryParam("edition_filter"),
AgeRatingFilter: c.QueryParam("age_rating_filter"),
FormatGroupFilter: c.QueryParam("format_group_filter"),
MangaTypeFilter: c.QueryParam("manga_type_filter"),
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
ExplicitFilter: boolFacet("explicit_filter"),
AbridgedFilter: boolFacet("abridged_filter"),
Sort: sortParam,
Limit: limit,
Offset: offset,
}
}
// libraryVisible reports whether the requested library is inside the
// requester's visible-library set. Slice compare on pgtype.UUID bytes —
// immune to string-format differences between the query param and the
// membership map keys.
func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (bool, error) {
_, ids, err := mh.visibleLibrarySet(c)
if err != nil {
return false, err
}
for _, id := range ids {
if id == libraryID {
return true, nil
}
}
return false, nil
}
// GetFacets handles GET /api/media-items/facets?facet=X&library_id=...
// — the filter panel's value+count listing (faceted-navigation Batch C).
// Registry-driven: the facet param must name a FacetArray or
// FacetScalar entry (bool/range facets render as toggles, not lists).
// Family-lens: an explicit library_id outside the visible set is 403.
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
facetParam := c.QueryParam("facet")
libraryID := c.QueryParam("library_id")
if facetParam == "" || libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet and library_id are required"})
}
facet := services.FacetByParam(facetParam)
if facet == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown facet: " + facetParam})
}
if facet.Kind != services.FacetArray && facet.Kind != services.FacetScalar {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet does not list values: " + facetParam})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg := pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
if err != nil {
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"facet": facetParam,
"values": values,
})
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {