diff --git a/cmd/server/tests/facets_integration_test.go b/cmd/server/tests/facets_integration_test.go
index 19b9c2c7..28298d8d 100644
--- a/cmd/server/tests/facets_integration_test.go
+++ b/cmd/server/tests/facets_integration_test.go
@@ -310,3 +310,261 @@ func mergeParams(base, extra url.Values) url.Values {
}
return out
}
+
+// facetFixtureBooks is the under-filter counts fixture: two fantasy
+// books (Pinecrest / Quartzmill) and one history book (Pinecrest).
+// Publisher names are trigram-disjoint on purpose — the scalar-fuzzy
+// filter is word_similarity, and near-identical names ("Ace Books" vs
+// "Baen Books") cross-match at 0.3+ (session gotcha).
+func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
+ t.Helper()
+ ctx := context.Background()
+ books := []struct {
+ title string
+ genre string
+ publisher string
+ }{
+ {prefix + " Alpha", "fantasy", "Pinecrest"},
+ {prefix + " Beta", "fantasy", "Quartzmill"},
+ {prefix + " Gamma", "history", "Pinecrest"},
+ }
+ for _, b := range books {
+ _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
+ LibraryID: libraryID,
+ Title: b.title,
+ Authors: []string{prefix + " Author"},
+ Genre: pgtype.Text{String: b.genre, Valid: true},
+ Publishers: []string{b.publisher},
+ Language: pgtype.Text{String: "en", Valid: true},
+ FilePath: fmt.Sprintf("/tmp/facet-counts/%s-%d.epub", b.title, time.Now().UnixNano()),
+ FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+ MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+ })
+ require.NoError(t, err)
+ }
+}
+
+// facetCountValue is one decoded facet value+count.
+type facetCountValue struct {
+ Value string `json:"value"`
+ Count float64 `json:"count"`
+}
+
+// facetCountsGet runs the all-facets endpoint (no facet param) and
+// decodes {"facets": {param: [{value, count}, …]}}.
+func facetCountsGet(t *testing.T, setup *TestServerSetup, params url.Values) (int, map[string][]facetCountValue) {
+ 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 struct {
+ Facets map[string][]facetCountValue `json:"facets"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ return resp.StatusCode, body.Facets
+}
+
+// requireFacetCount asserts one value's count within a facet.
+func requireFacetCount(t *testing.T, facet []facetCountValue, value string, want float64) {
+ t.Helper()
+ for _, v := range facet {
+ if v.Value == value {
+ require.Equal(t, want, v.Count, "facet value %q count", value)
+ return
+ }
+ }
+ require.Failf(t, "facet value missing", "%q not in facet", value)
+}
+
+// TestFacetCountsUnderFilters (faceted plan Phase 7): the all-facets
+// shape computes each facet's counts UNDER the other active filters —
+// a facet's own block is omitted from its arm (classic faceted
+// semantics: the genre list stays complete while genre filters the
+// rest), multi-select is OR-within, and the family lens holds on the
+// new shape too.
+func TestFacetCountsUnderFilters(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 Counts %d", time.Now().UnixNano())
+ library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
+ Name: libName,
+ Description: pgtype.Text{String: "facet under-filter counts 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) })
+
+ hiddenLib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
+ Name: fmt.Sprintf("Test Facet Counts Hidden %d", time.Now().UnixNano()),
+ Description: pgtype.Text{String: "hidden for the regular user", Valid: true},
+ LibraryTypeID: libType.ID,
+ CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
+ })
+ require.NoError(t, err)
+ hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, hiddenLib.ID) })
+ _, err = setup.DBPool.Exec(ctx,
+ `INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
+ hiddenLib.ID, userID)
+ require.NoError(t, err)
+
+ facetFixtureBooks(t, setup, library.ID, "Counts")
+ // A book in the HIDDEN library: distinctive values that must never
+ // surface for the regular user.
+ _, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
+ LibraryID: hiddenLib.ID,
+ Title: "Counts Hidden Book",
+ Authors: []string{"Counts Secret Author"},
+ Genre: pgtype.Text{String: "cryptobotany", Valid: true},
+ Publishers: []string{"Shadowpress"},
+ FilePath: fmt.Sprintf("/tmp/facet-counts/hidden-%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)
+
+ // No filters: the v1 global-per-library counts.
+ status, counts := facetCountsGet(t, setup, url.Values{"library_id": {libUUID.String()}})
+ require.Equal(t, http.StatusOK, status)
+ requireFacetCount(t, counts["genre"], "fantasy", 2)
+ requireFacetCount(t, counts["genre"], "history", 1)
+ requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
+ requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
+
+ // Under genre=fantasy: the genre facet STILL lists everything with
+ // unfiltered counts (its own block is omitted from its arm), while
+ // the publisher facet counts only within fantasy.
+ status, counts = facetCountsGet(t, setup, url.Values{
+ "library_id": {libUUID.String()},
+ "genre_filter": {"fantasy"},
+ })
+ require.Equal(t, http.StatusOK, status)
+ requireFacetCount(t, counts["genre"], "fantasy", 2)
+ requireFacetCount(t, counts["genre"], "history", 1)
+ requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
+ requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
+
+ // OR-within: both genres → Pinecrest regains Alpha (fantasy) and
+ // Gamma (history).
+ status, counts = facetCountsGet(t, setup, url.Values{
+ "library_id": {libUUID.String()},
+ "genre_filter": {"fantasy,history"},
+ })
+ require.Equal(t, http.StatusOK, status)
+ requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
+ requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
+
+ // AND-across: genre=history + publisher=Pinecrest → the genre facet
+ // is unfiltered by ITSELF (its own block is omitted) but still
+ // counts under the active publisher filter: both Pinecrest books
+ // (Alpha fantasy, Gamma history) surface as 1 each, Beta (Quartzmill)
+ // is filtered away. The publisher facet sees only history books, so
+ // Pinecrest counts Gamma alone.
+ status, counts = facetCountsGet(t, setup, url.Values{
+ "library_id": {libUUID.String()},
+ "genre_filter": {"history"},
+ "publisher_filter": {"Pinecrest"},
+ })
+ require.Equal(t, http.StatusOK, status)
+ requireFacetCount(t, counts["genre"], "fantasy", 1)
+ requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
+
+ // All-Libraries view (no library_id): the visible library's values
+ // are present, the hidden library's are absent everywhere.
+ status, counts = facetCountsGet(t, setup, url.Values{})
+ require.Equal(t, http.StatusOK, status)
+ requireFacetCount(t, counts["genre"], "fantasy", 2)
+ for _, v := range counts["genre"] {
+ require.NotEqual(t, "cryptobotany", v.Value, "hidden-library genre must be absent")
+ }
+ for _, v := range counts["publisher"] {
+ require.NotEqual(t, "Shadowpress", v.Value, "hidden-library publisher must be absent")
+ }
+
+ // Family lens: an explicit HIDDEN library_id on the new shape is a
+ // 403 (AGENTS standing rule — never a silent pass).
+ status, _ = facetCountsGet(t, setup, url.Values{"library_id": {hiddenUUID.String()}})
+ require.Equal(t, http.StatusForbidden, status)
+
+ // The pinned single-facet shape is untouched by the extension.
+ req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?facet=genre&library_id="+libUUID.String(), nil)
+ req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer func() { _ = resp.Body.Close() }()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var single struct {
+ Facet string `json:"facet"`
+ Values []facetCountValue `json:"values"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&single))
+ require.Equal(t, "genre", single.Facet)
+ requireFacetCount(t, single.Values, "fantasy", 2)
+ requireFacetCount(t, single.Values, "history", 1)
+}
+
+// TestFacetPanelPartial covers the drawer's SSR partial
+// (GET /media-items/facet-panel): server-rendered registry-ordered
+// sections, library-type scoping (Batch D), active-value marking, and
+// the family lens on the HTML surface.
+func TestFacetPanelPartial(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)
+ library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
+ Name: fmt.Sprintf("Test Facet Panel %d", time.Now().UnixNano()),
+ Description: pgtype.Text{String: "facet panel partial 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) })
+
+ facetFixtureBooks(t, setup, library.ID, "Panel")
+
+ // Baseline: genre + publisher sections render with prettified
+ // genre labels and counts; type-restricted facets stay off the
+ // ebooks view (Batch D).
+ code, html := ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String(), setup.RegularToken)
+ require.Equal(t, http.StatusOK, code)
+ require.Contains(t, html, `data-param="genre_filter"`)
+ require.Contains(t, html, `data-param="publisher_filter"`)
+ require.Contains(t, html, "Fantasy", "genre values render prettified")
+ require.Contains(t, html, `data-value="fantasy"`)
+ require.NotContains(t, html, `data-param="manga_type_filter"`, "manga_type is manga-only (Batch D)")
+ require.NotContains(t, html, `data-param="narrator_filter"`, "narrator is audiobooks-only (Batch D)")
+
+ // Under genre=fantasy: the fantasy button is marked active and the
+ // history value is STILL listed (its own block is omitted), proving
+ // the partial's counts come from the under-filter query.
+ code, html = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+libUUID.String()+"&genre_filter=fantasy", setup.RegularToken)
+ require.Equal(t, http.StatusOK, code)
+ require.Contains(t, html, "facet-value-active")
+ require.Contains(t, html, `data-value="history"`, "a facet's own filter never removes its values")
+
+ // Family lens on the HTML surface: an out-of-set library_id is 403.
+ code, _ = ssrGet(t, setup.Server.URL+"/media-items/facet-panel?library_id="+uuid.New().String(), setup.RegularToken)
+ require.Equal(t, http.StatusForbidden, code)
+}
diff --git a/docs/contract_pins.md b/docs/contract_pins.md
index 2f4e85a4..99abb9b6 100644
--- a/docs/contract_pins.md
+++ b/docs/contract_pins.md
@@ -257,3 +257,26 @@ cover_image_path, has_conflict, format_group, badge`.
from the live server's scans started reaching the socket first.
Keepalive is the standard protocol-level ping the hub already sends.
Never-built behavior is not a pinnable contract.
+
+## 2026-10-20 — Scanner Phase 7: all-facets shape + SSR facet panel
+
+- `GET /api/media-items/facets` gains a SECOND shape: with NO `facet`
+ param it answers `{"facets": {: [{"value","display?","count"},
+ …]}}` — every listable facet in one response, counts computed under
+ the ACTIVE FILTERS (the same query params the search endpoint takes;
+ each facet is counted under the others — its own filter does not
+ restrict its own list). `library_id` is optional on this shape:
+ absent = the All-Libraries view. Family-lens unchanged: an explicit
+ out-of-set `library_id` is 403 on both shapes.
+- The PINNED single-facet shape (`?facet=X&library_id=…` →
+ `{facet, values[]}`) is byte-identical in semantics; its
+ implementation now routes through the same UNION-ALL query with
+ neutral filters (the 14 per-column SQL queries were deleted — no
+ wire change).
+- NEW SSR partial: `GET /media-items/facet-panel` (session-cookie
+ auth, bookshelf-protected group) renders the drawer's value lists —
+ templ-rendered, registry order, library-type-scoped sections, active
+ values marked. An out-of-set explicit `library_id` is 403 HTML.
+- The 14 per-column facet SQL queries are replaced by one
+ `FacetCountsUnderFilters` UNION-ALL statement; the registry drift
+ guard now reflects over both generated param structs.
diff --git a/internal/database/querier.go b/internal/database/querier.go
index 17ba6d97..aed2e3d5 100644
--- a/internal/database/querier.go
+++ b/internal/database/querier.go
@@ -164,27 +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).
+ // Facet-value listings (faceted-navigation Batch C v2, faceted plan
+ // Phase 7): ALL listable facets' top values + counts in ONE statement —
+ // one round trip serves the filter drawer's panel, with classic faceted
+ // semantics: each arm OMITS its own facet's filter block and carries
+ // every OTHER registered facet's block (copied mechanically from
+ // SearchMediaItemsUnified — keep the two in lockstep), so with filters
+ // active each facet counts under the others, and with no filters the
+ // blocks are neutral and the counts are the v1 global-per-library ones.
+ // One query serves both cases.
+ //
// 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)
+ // hidden-library rows are absent; the handler 403s an out-of-set
+ // explicit library_id. library_id is nullable: NULL = the All-Libraries
+ // view (no scope beyond the lens). Bool/range facets (has_cover,
+ // explicit, abridged, year) filter every arm but list no values — they
+ // render as toggles/inputs, not lists.
+ //
+ // Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
+ // select bind per-arm only in parenthesized subqueries). The same
+ // sqlc.narg names repeat across arms; sqlc binds them once.
+ FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, 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 3eb0f0b8..5295b104 100644
--- a/internal/database/queries.sql.go
+++ b/internal/database/queries.sql.go
@@ -2347,55 +2347,9 @@ 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
-`
+const FacetCountsUnderFilters = `-- name: FacetCountsUnderFilters :many
-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
+(SELECT 'author'::text AS facet, a::text AS value, COUNT(*)::bigint 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
@@ -2403,376 +2357,67 @@ 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ -- author's own block omitted: a facet counts under the others
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT $21)
+UNION ALL
+(SELECT 'publisher'::text AS facet, p::text AS value, COUNT(*)::bigint 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
@@ -2780,184 +2425,135 @@ 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT $21)
+UNION ALL
+(SELECT 'narrator'::text AS facet, n::text AS value, COUNT(*)::bigint 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 mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
-GROUP BY mi.reading_direction
+ AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND n IS NOT NULL AND n != ''
+GROUP BY n
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
+LIMIT $21)
+UNION ALL
+(SELECT 'tags'::text AS facet, t::text AS value, COUNT(*)::bigint 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
@@ -2965,34 +2561,850 @@ 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
AND t IS NOT NULL AND t != ''
GROUP BY t
ORDER BY count DESC, value ASC
-LIMIT $3
+LIMIT $21)
+UNION ALL
+(SELECT 'series'::text AS facet, mi.series::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.series IS NOT NULL AND mi.series != ''
+GROUP BY mi.series
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'genre'::text AS facet, mi.genre::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.genre IS NOT NULL AND mi.genre != ''
+GROUP BY mi.genre
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'language'::text AS facet, mi.language::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.language IS NOT NULL AND mi.language != ''
+GROUP BY mi.language
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'story_arc'::text AS facet, mi.story_arc::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
+GROUP BY mi.story_arc
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'imprint'::text AS facet, mi.imprint::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) 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.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.imprint IS NOT NULL AND mi.imprint != ''
+GROUP BY mi.imprint
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'edition'::text AS facet, mi.edition::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.edition IS NOT NULL AND mi.edition != ''
+GROUP BY mi.edition
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'age_rating'::text AS facet, mi.age_rating::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
+GROUP BY mi.age_rating
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'format_group'::text AS facet, mi.format_group::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ AND mi.format_group IS NOT NULL AND mi.format_group != ''
+GROUP BY mi.format_group
+ORDER BY count DESC, value ASC
+LIMIT $21)
+UNION ALL
+(SELECT 'manga_type'::text AS facet, mi.manga_type::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($15 = '' OR mi.reading_direction = ANY(string_to_array($15, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ 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 $21)
+UNION ALL
+(SELECT 'reading_direction'::text AS facet, mi.reading_direction::text AS value, COUNT(*)::bigint 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 ($2::uuid IS NULL OR mi.library_id = $2::uuid)
+ AND ($22 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($22, ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND ($3 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($3, ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND ($4 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity($4, narrator) > 0.3
+ ))
+ AND ($5 = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity($5, tag) > 0.3
+ ))
+ AND ($6 = '' OR word_similarity($6, COALESCE(mi.series, '')) > 0.3)
+ AND ($7 = '' OR word_similarity($7, COALESCE(mi.genre, '')) > 0.3)
+ AND ($8 = '' OR word_similarity($8, COALESCE(mi.language, '')) > 0.3)
+ AND ($9 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($9, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
+ ))
+ AND ($10 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($10, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
+ ))
+ AND ($11 = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array($11, ',')) AS f
+ WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
+ ))
+ AND ($12 = '' OR mi.age_rating = ANY(string_to_array($12, ',')))
+ AND ($13 = '' OR mi.format_group = ANY(string_to_array($13, ',')))
+ AND ($14 = '' OR mi.manga_type = ANY(string_to_array($14, ',')))
+ AND ($16::bool IS NULL OR mi.explicit = $16::bool)
+ AND ($17::bool IS NULL OR mi.abridged = $17::bool)
+ AND (
+ $18 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= $18 OR
+ (mi.date_published IS NULL AND mi.copyright_year >= $18)
+ )
+ AND (
+ $19 = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= $19 OR
+ (mi.date_published IS NULL AND mi.copyright_year <= $19)
+ )
+ AND (
+ $20::bool IS NULL OR
+ ($20::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ ($20::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
+ 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 $21)
`
-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 FacetCountsUnderFiltersParams struct {
+ UserID pgtype.UUID `db:"user_id" json:"user_id"`
+ LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
+ PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
+ NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
+ TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
+ SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
+ GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
+ LanguageFilter interface{} `db:"language_filter" json:"language_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"`
+ Limit pgtype.Int4 `db:"limit" json:"limit"`
+ AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
}
-type FacetTagsValuesRow struct {
+type FacetCountsUnderFiltersRow struct {
+ Facet string `db:"facet" json:"facet"`
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)
+// Facet-value listings (faceted-navigation Batch C v2, faceted plan
+// Phase 7): ALL listable facets' top values + counts in ONE statement —
+// one round trip serves the filter drawer's panel, with classic faceted
+// semantics: each arm OMITS its own facet's filter block and carries
+// every OTHER registered facet's block (copied mechanically from
+// SearchMediaItemsUnified — keep the two in lockstep), so with filters
+// active each facet counts under the others, and with no filters the
+// blocks are neutral and the counts are the v1 global-per-library ones.
+// One query serves both cases.
+//
+// Visibility: same family-lens join as the search query — a user's
+// hidden-library rows are absent; the handler 403s an out-of-set
+// explicit library_id. library_id is nullable: NULL = the All-Libraries
+// view (no scope beyond the lens). Bool/range facets (has_cover,
+// explicit, abridged, year) filter every arm but list no values — they
+// render as toggles/inputs, not lists.
+//
+// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
+// select bind per-arm only in parenthesized subqueries). The same
+// sqlc.narg names repeat across arms; sqlc binds them once.
+func (q *Queries) FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error) {
+ rows, err := q.db.Query(ctx, FacetCountsUnderFilters,
+ arg.UserID,
+ arg.LibraryID,
+ arg.PublisherFilter,
+ arg.NarratorFilter,
+ arg.TagsFilter,
+ arg.SeriesFilter,
+ arg.GenreFilter,
+ arg.LanguageFilter,
+ arg.StoryArcFilter,
+ arg.ImprintFilter,
+ arg.EditionFilter,
+ arg.AgeRatingFilter,
+ arg.FormatGroupFilter,
+ arg.MangaTypeFilter,
+ arg.ReadingDirectionFilter,
+ arg.ExplicitFilter,
+ arg.AbridgedFilter,
+ arg.YearMin,
+ arg.YearMax,
+ arg.HasCover,
+ arg.Limit,
+ arg.AuthorFilter,
+ )
if err != nil {
return nil, err
}
defer rows.Close()
- items := []FacetTagsValuesRow{}
+ items := []FacetCountsUnderFiltersRow{}
for rows.Next() {
- var i FacetTagsValuesRow
- if err := rows.Scan(&i.Value, &i.Count); err != nil {
+ var i FacetCountsUnderFiltersRow
+ if err := rows.Scan(&i.Facet, &i.Value, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql
index 9ac968c2..b0abe38e 100644
--- a/internal/database/queries/queries.sql
+++ b/internal/database/queries/queries.sql
@@ -1006,16 +1006,29 @@ 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).
+-- Facet-value listings (faceted-navigation Batch C v2, faceted plan
+-- Phase 7): ALL listable facets' top values + counts in ONE statement —
+-- one round trip serves the filter drawer's panel, with classic faceted
+-- semantics: each arm OMITS its own facet's filter block and carries
+-- every OTHER registered facet's block (copied mechanically from
+-- SearchMediaItemsUnified — keep the two in lockstep), so with filters
+-- active each facet counts under the others, and with no filters the
+-- blocks are neutral and the counts are the v1 global-per-library ones.
+-- One query serves both cases.
+--
-- 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).
+-- hidden-library rows are absent; the handler 403s an out-of-set
+-- explicit library_id. library_id is nullable: NULL = the All-Libraries
+-- view (no scope beyond the lens). Bool/range facets (has_cover,
+-- explicit, abridged, year) filter every arm but list no values — they
+-- render as toggles/inputs, not lists.
+--
+-- Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
+-- select bind per-arm only in parenthesized subqueries). The same
+-- sqlc.narg names repeat across arms; sqlc binds them once.
--- name: FacetAuthorsValues :many
-SELECT a::text AS value, COUNT(*) AS count
+-- name: FacetCountsUnderFilters :many
+(SELECT 'author'::text AS facet, a::text AS value, COUNT(*)::bigint 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')
@@ -1023,14 +1036,67 @@ 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ -- author's own block omitted: a facet counts under the others
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'publisher'::text AS facet, p::text AS value, COUNT(*)::bigint 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')
@@ -1038,14 +1104,66 @@ 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'narrator'::text AS facet, n::text AS value, COUNT(*)::bigint 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')
@@ -1053,14 +1171,68 @@ 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'tags'::text AS facet, t::text AS value, COUNT(*)::bigint 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')
@@ -1068,151 +1240,765 @@ 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'series'::text AS facet, mi.series::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'genre'::text AS facet, mi.genre::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'language'::text AS facet, mi.language::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'story_arc'::text AS facet, mi.story_arc::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'imprint'::text AS facet, mi.imprint::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'edition'::text AS facet, mi.edition::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'age_rating'::text AS facet, mi.age_rating::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'format_group'::text AS facet, mi.format_group::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'manga_type'::text AS facet, mi.manga_type::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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
+LIMIT sqlc.narg('limit'))
+UNION ALL
+(SELECT 'reading_direction'::text AS facet, mi.reading_direction::text AS value, COUNT(*)::bigint 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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
+ AND (sqlc.narg('author_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
+ JOIN unnest(mi.authors) AS ae ON true
+ WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
+ ))
+ AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
+ JOIN unnest(mi.publishers) AS pe ON true
+ WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
+ ))
+ AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.narrators_search) AS narrator
+ WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
+ ))
+ AND (sqlc.narg('tags_filter') = '' OR EXISTS (
+ SELECT 1 FROM unnest(mi.tags_search) AS tag
+ WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
+ ))
+ AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
+ AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
+ AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
+ 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('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)
+ AND (
+ sqlc.narg('year_min') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
+ (mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
+ )
+ AND (
+ sqlc.narg('year_max') = 0 OR
+ EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
+ (mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
+ )
+ AND (
+ sqlc.narg('has_cover')::bool IS NULL OR
+ (sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
+ (sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
+ )
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');
+LIMIT sqlc.narg('limit'));
+
-- Media Notes queries
-- name: GetMediaNote :one
diff --git a/internal/handlers/media.go b/internal/handlers/media.go
index 910f56d5..34de4ef8 100644
--- a/internal/handlers/media.go
+++ b/internal/handlers/media.go
@@ -2446,22 +2446,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
}
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"),
+ 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"),
@@ -2490,11 +2490,21 @@ func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (
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.
+// GetFacets handles GET /api/media-items/facets — the filter panel's
+// value+count listing (faceted-navigation Batch C, extended in Phase 7).
+//
+// Two shapes:
+// - ?facet=X&library_id=… → the PINNED single-facet shape
+// ({facet, values[]}); facet must name a FacetArray or FacetScalar
+// entry (bool/range facets render as toggles, not lists).
+// - no facet param → the all-facets shape ({"facets": {param:
+// [value,…]}}), computed under the ACTIVE FILTERS (the same query
+// params the search endpoint takes) — each facet counts under the
+// others, the SQL omitting each arm's own block. library_id is
+// optional: absent = the All-Libraries view.
+//
+// Family-lens: an explicit library_id outside the visible set is 403
+// in both shapes.
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
@@ -2503,8 +2513,43 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
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"})
+
+ limit, _ := strconv.Atoi(c.QueryParam("limit"))
+ if limit == 0 {
+ limit = 50
+ }
+
+ if facetParam == "" {
+ // All-facets shape: filters bind exactly like the search
+ // endpoint's (ParseMediaSearchParams), so the panel's counts
+ // and the grid's rows can never disagree about what's active.
+ var libPg pgtype.UUID
+ if libraryID != "" {
+ 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"})
+ }
+ }
+ counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
+ if err != nil {
+ c.Logger().Error("facet counts error", "error", err.Error())
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ }
+ return c.JSON(http.StatusOK, map[string]interface{}{
+ "facets": counts,
+ })
+ }
+
+ if libraryID == "" {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
}
facet := services.FacetByParam(facetParam)
if facet == nil {
@@ -2527,11 +2572,6 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
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)
@@ -2543,6 +2583,13 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
})
}
+// FacetCountsForView is the SSR facet panel's data path (faceted plan
+// Phase 7): the all-facets counts under the active filters for one
+// library — or across every visible library when libraryID is NULL.
+func (mh *MediaHandler) FacetCountsForView(ctx context.Context, userID, libraryID pgtype.UUID, params services.SearchParams, limit int) (map[string][]services.FacetValue, error) {
+ return mh.searchService.FacetCounts(ctx, userID, libraryID, params, limit)
+}
+
// 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/frontend.go b/internal/router/frontend.go
index 9dfdb830..d2151559 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -386,6 +386,129 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
+ // Facet panel partial (faceted plan Phase 7): the bookshelf filter
+ // drawer's value-list sections, server-rendered from the facet
+ // registry + the under-filter counts. The drawer fetches it with the
+ // CURRENT filter query string, so the counts always reflect exactly
+ // what the grid shows; a value click is bookshelf.ts toggling that
+ // facet's filter and re-applying the form.
+ frontendProtected.GET("/media-items/facet-panel", func(c *echo.Context) error {
+ user, err := getTemplateUserWithTheme(c, cfg)
+ if err != nil {
+ return renderErrorPage(c, "Error loading user", "user_load_error")
+ }
+
+ // Family lens (AGENTS standing rule): the panel lists media
+ // metadata, so an explicit library_id outside the visible set is
+ // a 403 — never a silent fallback.
+ if requested := c.QueryParam("library_id"); requested != "" {
+ if requestedUUID, parseErr := uuid.Parse(requested); parseErr == nil {
+ visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
+ if visErr != nil {
+ log.Printf("visibleLibraryIDsFor failed: %v", visErr)
+ return c.HTML(http.StatusInternalServerError, "