diff --git a/cmd/server/tests/facets_integration_test.go b/cmd/server/tests/facets_integration_test.go new file mode 100644 index 00000000..19b9c2c7 --- /dev/null +++ b/cmd/server/tests/facets_integration_test.go @@ -0,0 +1,312 @@ +package main + +// Facet registry Batch A integration tests (faceted-navigation plan, +// Part 1 Phase 1): the nine new SearchMediaItemsUnified narg blocks — +// scalar-fuzzy (story_arc, imprint, edition), exact enums (age_rating, +// format_group, manga_type, reading_direction), and presence-bound +// bools (explicit, abridged) — filter correctly against a real DB. +// The registry↔SQL drift guard (unit level) lives in +// internal/services/facet_registry_test.go. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// facetSearch runs the unified search endpoint with arbitrary query +// params and returns (status, decoded array-or-object). +func facetSearch(t *testing.T, setup *TestServerSetup, params url.Values) (int, []map[string]interface{}) { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound { + // The endpoint's no-results contract: 404 + error envelope. + return resp.StatusCode, nil + } + require.Equal(t, http.StatusOK, resp.StatusCode) + var rows []map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows)) + return resp.StatusCode, rows +} + +func facetTitles(t *testing.T, rows []map[string]interface{}) []string { + t.Helper() + var out []string + for _, r := range rows { + title, _ := r["title"].(string) + out = append(out, title) + } + return out +} + +func TestFacetRegistryParamsFilter(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + libName := fmt.Sprintf("Test Facet Params Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: libName, + Description: pgtype.Text{String: "facet params test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err) + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) }) + + createItem := func(title string, mutate func(*database.CreateMediaItemParams)) { + t.Helper() + p := database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: title, + Authors: []string{"Facet Param Author"}, + FilePath: fmt.Sprintf("/tmp/facet-params/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + } + if mutate != nil { + mutate(&p) + } + _, err := setup.DB.CreateMediaItem(ctx, p) + require.NoError(t, err, "create %s", title) + } + + createItem("Facet Alpha", func(p *database.CreateMediaItemParams) { + p.StoryArc = pgtype.Text{String: "civil war", Valid: true} + p.Imprint = pgtype.Text{String: "Vertigo", Valid: true} + p.Edition = pgtype.Text{String: "First Edition", Valid: true} + p.AgeRating = pgtype.Text{String: "Teen", Valid: true} + p.MangaType = pgtype.Text{String: "yes", Valid: true} + p.Explicit = true + }) + createItem("Facet Beta", func(p *database.CreateMediaItemParams) { + p.StoryArc = pgtype.Text{String: "infinity gauntlet", Valid: true} + p.AgeRating = pgtype.Text{String: "Mature", Valid: true} + p.ReadingDirection = pgtype.Text{String: "rtl", Valid: true} + p.Abridged = true + }) + + base := url.Values{"library_id": []string{libUUID.String()}} + titles := func(params url.Values) []string { + status, rows := facetSearch(t, setup, mergeParams(base, params)) + require.Equal(t, http.StatusOK, status) + return facetTitles(t, rows) + } + + // Unfiltered: both items, the additive params are default-neutral. + got := titles(url.Values{}) + assert.Len(t, got, 2, "unfiltered search returns both items") + + // Scalar-fuzzy: single value... + got = titles(url.Values{"story_arc_filter": {"civil war"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "story_arc scalar-fuzzy") + got = titles(url.Values{"imprint_filter": {"Vertigo"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "imprint scalar-fuzzy") + got = titles(url.Values{"edition_filter": {"First Edition"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "edition scalar-fuzzy") + // ...and comma = OR-within (multi-select). + got = titles(url.Values{"story_arc_filter": {"civil war,infinity gauntlet"}}) + assert.Len(t, got, 2, "comma is OR-within the facet") + + // Exact enums: single + multi. + got = titles(url.Values{"age_rating_filter": {"Teen"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "age_rating exact") + got = titles(url.Values{"age_rating_filter": {"Teen,Mature"}}) + assert.Len(t, got, 2, "age_rating multi-select") + got = titles(url.Values{"format_group_filter": {"reflowable"}}) + assert.Len(t, got, 2, "format_group exact (create default)") + status, _ := facetSearch(t, setup, mergeParams(base, url.Values{"format_group_filter": {"audiobook"}})) + assert.Equal(t, http.StatusNotFound, status, "wrong format_group matches nothing") + got = titles(url.Values{"reading_direction_filter": {"rtl"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "reading_direction exact") + got = titles(url.Values{"manga_type_filter": {"yes"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "manga_type exact (works cross-type by data)") + + // Presence-bound bools: absent = no filter, value = exact. + got = titles(url.Values{"explicit_filter": {"true"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "explicit=true") + got = titles(url.Values{"explicit_filter": {"false"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "explicit=false") + got = titles(url.Values{"abridged_filter": {"true"}}) + assert.Equal(t, []string{"Facet Beta"}, got, "abridged=true") + + // AND-across-facets: story_arc + age_rating intersect to Alpha. + got = titles(url.Values{"story_arc_filter": {"civil war"}, "age_rating_filter": {"Teen"}}) + assert.Equal(t, []string{"Facet Alpha"}, got, "AND-across-facets") + + // Language UX (Batch B): a display name normalizes through the + // ingest map — "English" matches the row stored as "en". + createItem("Facet Gamma", func(p *database.CreateMediaItemParams) { + p.Language = pgtype.Text{String: "en", Valid: true} + }) + got = titles(url.Values{"language_filter": {"English"}}) + assert.Equal(t, []string{"Facet Gamma"}, got, "display name matches ISO-stored rows") + got = titles(url.Values{"language_filter": {"en"}}) + assert.Equal(t, []string{"Facet Gamma"}, got, "raw code still matches") +} + +// TestFacetsEndpoint covers GET /api/media-items/facets (Batch C): the +// registry-driven value+count listing, language display names, and the +// family-lens gates (hidden library → 403 on BOTH the facets and search +// endpoints; bool/range facets → 400 because they render as toggles). +func TestFacetsEndpoint(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + userID := getRegularUserID(t, setup.DB) + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + libName := fmt.Sprintf("Test Facet Listing Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: libName, + Description: pgtype.Text{String: "facet listing test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err) + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) }) + + // Two items share the genre; one carries the language + narrator. + for _, title := range []string{"Facet List One", "Facet List Two"} { + _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: title, + Authors: []string{"Facet Listing Author"}, + Genre: pgtype.Text{String: "fantasy", Valid: true}, + Language: pgtype.Text{String: "en", Valid: true}, + FilePath: fmt.Sprintf("/tmp/facet-listing/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + } + _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: library.ID, + Title: "Facet List Three", + Authors: []string{"Facet Listing Author"}, + Genre: pgtype.Text{String: "horror", Valid: true}, + Narrators: []string{"Facet Listing Narrator"}, + FilePath: fmt.Sprintf("/tmp/facet-listing/three-%d.epub", time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err) + + getFacets := func(params url.Values) (int, map[string]interface{}) { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.Encode(), nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 { + return resp.StatusCode, nil + } + var body map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + return resp.StatusCode, body + } + + // Counts correct + ordered by count desc. + status, body := getFacets(url.Values{ + "facet": {"genre"}, + "library_id": {libUUID.String()}, + }) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "genre", body["facet"]) + values, _ := body["values"].([]interface{}) + require.Len(t, values, 2) + first, _ := values[0].(map[string]interface{}) + second, _ := values[1].(map[string]interface{}) + assert.Equal(t, "fantasy", first["value"]) + assert.Equal(t, float64(2), first["count"], "two items share the genre") + assert.Equal(t, "horror", second["value"]) + assert.Equal(t, float64(1), second["count"]) + + // Language values carry display names (server owns display). + status, body = getFacets(url.Values{ + "facet": {"language"}, + "library_id": {libUUID.String()}, + }) + require.Equal(t, http.StatusOK, status) + values, _ = body["values"].([]interface{}) + require.Len(t, values, 1) + lang, _ := values[0].(map[string]interface{}) + assert.Equal(t, "en", lang["value"], "the raw code rides the param") + assert.Equal(t, "English", lang["display"], "the name is for display") + + // Bool facets render as toggles, not lists → 400. + status, _ = getFacets(url.Values{ + "facet": {"explicit"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusBadRequest, status) + + // Unknown facet → 400. + status, _ = getFacets(url.Values{ + "facet": {"woodcut"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusBadRequest, status) + + // Family-lens: a library explicitly hidden from the regular user is + // a 403 on the facets endpoint AND on the search endpoint (an + // explicit library_id outside the visible set is never silently + // empty — AGENTS.md → Library visibility). + _, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{ + UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + LibraryID: library.ID, + IsVisible: false, + }) + require.NoError(t, err) + status, _ = getFacets(url.Values{ + "facet": {"genre"}, + "library_id": {libUUID.String()}, + }) + assert.Equal(t, http.StatusForbidden, status, "hidden library → 403") + + searchReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?library_id="+libUUID.String(), nil) + searchReq.Header.Set("Authorization", "Bearer "+setup.RegularToken) + searchResp, err := http.DefaultClient.Do(searchReq) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, searchResp.Body) + _ = searchResp.Body.Close() + assert.Equal(t, http.StatusForbidden, searchResp.StatusCode, "search with hidden library_id → 403") +} + +func mergeParams(base, extra url.Values) url.Values { + out := url.Values{} + for k, v := range base { + out[k] = v + } + for k, v := range extra { + out[k] = v + } + return out +} diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 7d9ad509..3ea1487b 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -161,3 +161,22 @@ cover_image_path, has_conflict, format_group, badge`. - `author_filter` / `publisher_filter` are now per-element against the arrays; a comma-joined value is OR-within-facet multi-select. The param NAMES and single-value behavior are unchanged. + +## 2026-10-19 — Faceted navigation Batch A: new filter facets + +- `GET /api/media-items/search` accepts ADDITIVE REQUEST params (query + string, response shapes unchanged): `story_arc_filter`, + `imprint_filter`, `edition_filter` (scalar-fuzzy), + `age_rating_filter`, `format_group_filter`, `manga_type_filter`, + `reading_direction_filter` (exact), `explicit_filter`, + `abridged_filter` (bool: absent = unset, "true"/"false" = bound). + A comma-joined value is OR-within-facet multi-select on every new + TEXT facet (the SQL splits via string_to_array). `services/facet_registry.go` + is the Go-side source of truth; the drift-guard unit test + (`TestFacetRegistrySQLDrift`) pins registry ↔ SQL agreement. +- Genre model v2 changes SCANNER OUTPUT (not a wire contract): sidecar + genre selection now stores only top-level shelves in `genre` + (subgenres become tags mapped to their parent shelf; unknown-only + input leaves `genre` empty). Existing rows are unaffected until a + rescan/re-extract. + diff --git a/internal/database/querier.go b/internal/database/querier.go index 8a68c140..0aef411b 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -164,6 +164,27 @@ type Querier interface { // historically stuff the user id into DeviceID (it only fed a broadcast // label before stats); a capture row must only reference a REAL device. DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error) + FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error) + // 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). + FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error) + FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error) + FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error) + FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error) + FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error) + FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error) + FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error) + FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error) + FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error) + FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error) + FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error) + FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error) + FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error) // Find content-duplicate groups (same library + SHA-256, more than one row) FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 69b4034b..c92e546e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -2347,6 +2347,662 @@ func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUs return has_device, err } +const FacetAgeRatingValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.age_rating IS NOT NULL AND mi.age_rating != '' +GROUP BY mi.age_rating +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetAgeRatingValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetAgeRatingValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error) { + rows, err := q.db.Query(ctx, FacetAgeRatingValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetAgeRatingValuesRow{} + for rows.Next() { + var i FacetAgeRatingValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetAuthorsValues = `-- 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 = $1 +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 = $2 + AND a IS NOT NULL AND a != '' +GROUP BY a +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetAuthorsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetAuthorsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +// 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). +func (q *Queries) FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetAuthorsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetAuthorsValuesRow{} + for rows.Next() { + var i FacetAuthorsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetEditionValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.edition IS NOT NULL AND mi.edition != '' +GROUP BY mi.edition +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetEditionValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetEditionValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error) { + rows, err := q.db.Query(ctx, FacetEditionValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetEditionValuesRow{} + for rows.Next() { + var i FacetEditionValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetFormatGroupValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.format_group IS NOT NULL AND mi.format_group != '' +GROUP BY mi.format_group +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetFormatGroupValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetFormatGroupValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error) { + rows, err := q.db.Query(ctx, FacetFormatGroupValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetFormatGroupValuesRow{} + for rows.Next() { + var i FacetFormatGroupValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetGenreValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.genre IS NOT NULL AND mi.genre != '' +GROUP BY mi.genre +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetGenreValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetGenreValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error) { + rows, err := q.db.Query(ctx, FacetGenreValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetGenreValuesRow{} + for rows.Next() { + var i FacetGenreValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetImprintValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.imprint IS NOT NULL AND mi.imprint != '' +GROUP BY mi.imprint +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetImprintValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetImprintValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error) { + rows, err := q.db.Query(ctx, FacetImprintValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetImprintValuesRow{} + for rows.Next() { + var i FacetImprintValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetLanguageValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.language IS NOT NULL AND mi.language != '' +GROUP BY mi.language +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetLanguageValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetLanguageValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error) { + rows, err := q.db.Query(ctx, FacetLanguageValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetLanguageValuesRow{} + for rows.Next() { + var i FacetLanguageValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetMangaTypeValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + 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 $3 +` + +type FacetMangaTypeValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetMangaTypeValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error) { + rows, err := q.db.Query(ctx, FacetMangaTypeValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetMangaTypeValuesRow{} + for rows.Next() { + var i FacetMangaTypeValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetNarratorsValues = `-- 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 = $1 +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 = $2 + AND n IS NOT NULL AND n != '' +GROUP BY n +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetNarratorsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetNarratorsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetNarratorsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetNarratorsValuesRow{} + for rows.Next() { + var i FacetNarratorsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetPublishersValues = `-- 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 = $1 +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 = $2 + AND p IS NOT NULL AND p != '' +GROUP BY p +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetPublishersValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetPublishersValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error) { + rows, err := q.db.Query(ctx, FacetPublishersValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetPublishersValuesRow{} + for rows.Next() { + var i FacetPublishersValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetReadingDirectionValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + 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 $3 +` + +type FacetReadingDirectionValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetReadingDirectionValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error) { + rows, err := q.db.Query(ctx, FacetReadingDirectionValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetReadingDirectionValuesRow{} + for rows.Next() { + var i FacetReadingDirectionValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetSeriesValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.series IS NOT NULL AND mi.series != '' +GROUP BY mi.series +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetSeriesValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetSeriesValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error) { + rows, err := q.db.Query(ctx, FacetSeriesValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetSeriesValuesRow{} + for rows.Next() { + var i FacetSeriesValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetStoryArcValues = `-- 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 = $1 +WHERE COALESCE(lv.is_visible, true) = true + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 + AND mi.library_id = $2 + AND mi.story_arc IS NOT NULL AND mi.story_arc != '' +GROUP BY mi.story_arc +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetStoryArcValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetStoryArcValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error) { + rows, err := q.db.Query(ctx, FacetStoryArcValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetStoryArcValuesRow{} + for rows.Next() { + var i FacetStoryArcValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const FacetTagsValues = `-- 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 = $1 +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 = $2 + AND t IS NOT NULL AND t != '' +GROUP BY t +ORDER BY count DESC, value ASC +LIMIT $3 +` + +type FacetTagsValuesParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + Limit pgtype.Int4 `db:"limit" json:"limit"` +} + +type FacetTagsValuesRow struct { + Value string `db:"value" json:"value"` + Count int64 `db:"count" json:"count"` +} + +func (q *Queries) FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error) { + rows, err := q.db.Query(ctx, FacetTagsValues, arg.UserID, arg.LibraryID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FacetTagsValuesRow{} + for rows.Next() { + var i FacetTagsValuesRow + if err := rows.Scan(&i.Value, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const FindHashConflictGroups = `-- name: FindHashConflictGroups :many SELECT library_id, file_sha256, COUNT(*) AS dup_count FROM media_items @@ -13257,44 +13913,69 @@ WHERE COALESCE(lv.is_visible, true) = true SELECT 1 FROM unnest(mi.narrators_search) AS narrator WHERE word_similarity($9, 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 ($10 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($10, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3 + )) + AND ($11 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($11, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3 + )) + AND ($12 = '' OR EXISTS ( + SELECT 1 + FROM unnest(string_to_array($12, ',')) AS f + WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3 + )) + AND ($13 = '' OR mi.age_rating = ANY(string_to_array($13, ','))) + AND ($14 = '' OR mi.format_group = ANY(string_to_array($14, ','))) + AND ($15 = '' OR mi.manga_type = ANY(string_to_array($15, ','))) + AND ($16 = '' OR mi.reading_direction = ANY(string_to_array($16, ','))) + AND ($17::bool IS NULL OR mi.explicit = $17::bool) + AND ($18::bool IS NULL OR mi.abridged = $18::bool) -- Year range (exact) - prioritize date_published, fallback to copyright_year AND ( - $10 = 0 OR - EXTRACT(YEAR FROM mi.date_published) >= $10 OR - (mi.date_published IS NULL AND mi.copyright_year >= $10) + $19 = 0 OR + EXTRACT(YEAR FROM mi.date_published) >= $19 OR + (mi.date_published IS NULL AND mi.copyright_year >= $19) ) AND ( - $11 = 0 OR - EXTRACT(YEAR FROM mi.date_published) <= $11 OR - (mi.date_published IS NULL AND mi.copyright_year <= $11) + $20 = 0 OR + EXTRACT(YEAR FROM mi.date_published) <= $20 OR + (mi.date_published IS NULL AND mi.copyright_year <= $20) ) -- Boolean (exact) AND ( - $12::bool IS NULL OR -- Not specified = show all - ($12::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR - ($12::bool IS FALSE AND mi.cover_image_path IS NULL) + $21::bool IS NULL OR -- Not specified = show all + ($21::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR + ($21::bool IS FALSE AND mi.cover_image_path IS NULL) ) -- Search query (fuzzy or exact based on quotes) AND ( - $13 = '' OR + $22 = '' OR -- Fuzzy search (default) - $14 = false AND ( - word_similarity($13, mi.title) > 0.3 OR - word_similarity($13, COALESCE(mi.author, '')) > 0.3 OR - word_similarity($13, COALESCE(mi.series, '')) > 0.3 OR + $23 = false AND ( + word_similarity($22, mi.title) > 0.3 OR + word_similarity($22, COALESCE(mi.author, '')) > 0.3 OR + word_similarity($22, COALESCE(mi.series, '')) > 0.3 OR EXISTS ( SELECT 1 FROM unnest(mi.tags_search) AS tag - WHERE word_similarity($13, tag) > 0.3 + WHERE word_similarity($22, tag) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.contributors_search) AS contributor - WHERE word_similarity($13, contributor) > 0.3 + WHERE word_similarity($22, contributor) > 0.3 LIMIT 1 ) OR EXISTS ( SELECT 1 FROM unnest(mi.narrators_search) AS narrator - WHERE word_similarity($13, narrator) > 0.3 + WHERE word_similarity($22, narrator) > 0.3 LIMIT 1 ) ) OR @@ -13308,25 +13989,25 @@ WHERE COALESCE(lv.is_visible, true) = true -- sqlc.narg('search_pattern') = ANY(mi.contributors_search) -- ) -- Exact search (with quotes) - true exact match, not substring - $14 = true AND ( - mi.title = $13 OR - COALESCE(mi.author, '') = $13 OR - COALESCE(mi.series, '') = $13 OR - $13 = ANY(mi.tags_search) OR - $13 = ANY(mi.contributors_search) OR - $13 = ANY(mi.narrators_search) + $23 = true AND ( + mi.title = $22 OR + COALESCE(mi.author, '') = $22 OR + COALESCE(mi.series, '') = $22 OR + $22 = ANY(mi.tags_search) OR + $22 = ANY(mi.contributors_search) OR + $22 = ANY(mi.narrators_search) ) ) ORDER BY -- Primary sort: relevance score when searching CASE - WHEN $13 != '' THEN + WHEN $22 != '' THEN GREATEST( - CASE WHEN $14 = false THEN - word_similarity($13, mi.title) + CASE WHEN $23 = false THEN + word_similarity($22, mi.title) ELSE 0 END, - CASE WHEN $14 = false THEN - word_similarity($13, COALESCE(mi.author, '')) + CASE WHEN $23 = false THEN + word_similarity($22, COALESCE(mi.author, '')) ELSE 0 END, word_similarity($3, COALESCE(mi.author, '')), word_similarity($5, COALESCE(mi.genre, '')), @@ -13338,60 +14019,69 @@ ORDER BY END DESC, -- Secondary sort: user-specified sort parameter CASE - WHEN $15 = 'title ASC' THEN mi.title + WHEN $24 = 'title ASC' THEN mi.title ELSE '' END ASC, CASE - WHEN $15 = 'title DESC' THEN mi.title + WHEN $24 = 'title DESC' THEN mi.title ELSE '' END DESC, CASE - WHEN $15 = 'author ASC' THEN COALESCE(mi.author, '') + WHEN $24 = 'author ASC' THEN COALESCE(mi.author, '') ELSE '' END ASC, CASE - WHEN $15 = 'author DESC' THEN COALESCE(mi.author, '') + WHEN $24 = 'author DESC' THEN COALESCE(mi.author, '') ELSE '' END DESC, CASE - WHEN $15 = 'created_at ASC' THEN mi.created_at + WHEN $24 = 'created_at ASC' THEN mi.created_at ELSE '1970-01-01'::timestamp END ASC, CASE - WHEN $15 = 'created_at DESC' THEN mi.created_at + WHEN $24 = 'created_at DESC' THEN mi.created_at ELSE '1970-01-01'::timestamp END DESC, CASE - WHEN $15 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') + WHEN $24 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END ASC, CASE - WHEN $15 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') + WHEN $24 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0') ELSE '' END DESC, -- Tertiary sort: title (default fallback) mi.title ASC -LIMIT $17 OFFSET $16 +LIMIT $26 OFFSET $25 ` type SearchMediaItemsUnifiedParams struct { - UserID pgtype.UUID `db:"user_id" json:"user_id"` - LibraryID pgtype.UUID `db:"library_id" json:"library_id"` - AuthorFilter interface{} `db:"author_filter" json:"author_filter"` - SeriesFilter interface{} `db:"series_filter" json:"series_filter"` - GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` - PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"` - LanguageFilter interface{} `db:"language_filter" json:"language_filter"` - TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` - NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"` - YearMin interface{} `db:"year_min" json:"year_min"` - YearMax interface{} `db:"year_max" json:"year_max"` - HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` - SearchQuery interface{} `db:"search_query" json:"search_query"` - IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` - Sort interface{} `db:"sort" json:"sort"` - Offset pgtype.Int4 `db:"offset" json:"offset"` - Limit pgtype.Int4 `db:"limit" json:"limit"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + AuthorFilter interface{} `db:"author_filter" json:"author_filter"` + SeriesFilter interface{} `db:"series_filter" json:"series_filter"` + GenreFilter interface{} `db:"genre_filter" json:"genre_filter"` + PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"` + LanguageFilter interface{} `db:"language_filter" json:"language_filter"` + TagsFilter interface{} `db:"tags_filter" json:"tags_filter"` + NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"` + StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"` + ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"` + EditionFilter interface{} `db:"edition_filter" json:"edition_filter"` + AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"` + FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"` + MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"` + ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"` + ExplicitFilter pgtype.Bool `db:"explicit_filter" json:"explicit_filter"` + AbridgedFilter pgtype.Bool `db:"abridged_filter" json:"abridged_filter"` + YearMin interface{} `db:"year_min" json:"year_min"` + YearMax interface{} `db:"year_max" json:"year_max"` + HasCover pgtype.Bool `db:"has_cover" json:"has_cover"` + SearchQuery interface{} `db:"search_query" json:"search_query"` + IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"` + Sort interface{} `db:"sort" json:"sort"` + Offset pgtype.Int4 `db:"offset" json:"offset"` + Limit pgtype.Int4 `db:"limit" json:"limit"` } type SearchMediaItemsUnifiedRow struct { @@ -13464,6 +14154,15 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt arg.LanguageFilter, arg.TagsFilter, arg.NarratorFilter, + arg.StoryArcFilter, + arg.ImprintFilter, + arg.EditionFilter, + arg.AgeRatingFilter, + arg.FormatGroupFilter, + arg.MangaTypeFilter, + arg.ReadingDirectionFilter, + arg.ExplicitFilter, + arg.AbridgedFilter, arg.YearMin, arg.YearMax, arg.HasCover, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 9fa8d7b8..d30ed23f 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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; diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 5c72fba4..910f56d5 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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 { diff --git a/internal/router/search.go b/internal/router/search.go index 5a54a78c..50d4518e 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -3,15 +3,11 @@ package router import ( "bookhoard/internal/database" "bookhoard/internal/handlers" - "bookhoard/internal/services" "bookhoard/templates" "bytes" "log" "net/http" - "strconv" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" ) @@ -30,6 +26,9 @@ func registerSearchRoutes(cfg *Config) { // Otherwise return JSON (API clients) return cfg.MediaHandler.SearchMediaItems(c) }) + // Facet-value listings for the filter panel (faceted-navigation + // Batch C): /api/media-items/facets?facet=X&library_id=... + protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets) protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks) } @@ -39,60 +38,37 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { if !ok { return c.String(http.StatusUnauthorized, "Unauthorized") } - // Extract query parameters - query := c.QueryParam("q") + // Extract filters via the SHARED parser (handlers.ParseMediaSearchParams) + // — the HTMX grid partial and the JSON endpoint can never disagree on + // which facet params exist or how they parse. (This branch previously + // dropped publisher_filter and narrator_filter entirely — the shared + // parser fixes that too.) + params := handlers.ParseMediaSearchParams(c) + params.UserID = user.ID + limit := params.Limit + offset := params.Offset libraryID := c.QueryParam("library_id") - limit, _ := strconv.Atoi(c.QueryParam("limit")) - if limit == 0 { - limit = 50 - } - offset, _ := strconv.Atoi(c.QueryParam("offset")) - // Validate library_id - var libUUID pgtype.UUID - if libraryID != "" { - lib, err := uuid.Parse(libraryID) + + // Family-lens gate: an explicit library_id outside the requester's + // visible set is a 403 (same rule as the JSON branch). + if params.LibraryID.Valid { + _, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID) if err != nil { - return c.String(http.StatusBadRequest, "invalid library_id") + log.Printf("VisibleLibrarySet failed: %v", err) + return c.HTML(http.StatusInternalServerError, `