feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.
- Nine new filter params on SearchMediaItemsUnified: story_arc,
imprint, edition (scalar-fuzzy), age_rating, format_group,
manga_type, reading_direction (exact), explicit, abridged
(presence-bound bools). Comma = OR-within multi-select on every
text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
> empty (unknown-only stays tags-only). Type-words (Manga, Comics,
Graphic Novel) join the demote list. Both 'adventure' and 'action &
adventure' are shelves — provider data uses both and the verified
traces pin them.
- Language filter UX: comma elements normalize through the ingest
language map, so display names ('English') match rows stored as ISO
codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
driven value+count listing (14 static per-column queries, family-lens
join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
403 on the search endpoints (was a silent empty set) — JSON branch
and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
search JSON endpoint and the HTMX grid partial; the HTML branch
previously dropped publisher_filter and narrator_filter silently.
Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
This commit is contained in:
1 parent
7537063cb7
commit
7e586df48a
13 files changed
+2297
-283
No files matched your search
@@ -164,6 +164,27 @@ type Querier interface {
|
||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
// label before stats); a capture row must only reference a REAL device.
|
||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||
FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error)
|
||||
// Facet-value listings (faceted-navigation Batch C): top values + counts
|
||||
// for the filter panel, one static query per media_items column (same
|
||||
// sqlc-compatibility constraint as the Search*Values autocomplete above).
|
||||
// Visibility: same family-lens join as the search query — a user's
|
||||
// hidden-library rows are absent, and the handler 403s an out-of-set
|
||||
// explicit library_id. Counts are v1 global-per-library (not
|
||||
// recomputed under the other active filters).
|
||||
FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error)
|
||||
FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error)
|
||||
FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error)
|
||||
FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error)
|
||||
FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error)
|
||||
FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error)
|
||||
FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error)
|
||||
FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error)
|
||||
FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error)
|
||||
FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error)
|
||||
FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error)
|
||||
FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error)
|
||||
FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error)
|
||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
|
||||
@@ -2347,6 +2347,662 @@ func (q *Queries) DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUs
|
||||
return has_device, err
|
||||
}
|
||||
|
||||
const FacetAgeRatingValues = `-- name: FacetAgeRatingValues :many
|
||||
SELECT mi.age_rating::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
|
||||
GROUP BY mi.age_rating
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetAgeRatingValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetAgeRatingValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetAgeRatingValues(ctx context.Context, arg FacetAgeRatingValuesParams) ([]FacetAgeRatingValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetAgeRatingValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetAgeRatingValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetAgeRatingValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetAuthorsValues = `-- name: FacetAuthorsValues :many
|
||||
|
||||
SELECT a::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND a IS NOT NULL AND a != ''
|
||||
GROUP BY a
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetAuthorsValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetAuthorsValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
// Facet-value listings (faceted-navigation Batch C): top values + counts
|
||||
// for the filter panel, one static query per media_items column (same
|
||||
// sqlc-compatibility constraint as the Search*Values autocomplete above).
|
||||
// Visibility: same family-lens join as the search query — a user's
|
||||
// hidden-library rows are absent, and the handler 403s an out-of-set
|
||||
// explicit library_id. Counts are v1 global-per-library (not
|
||||
// recomputed under the other active filters).
|
||||
func (q *Queries) FacetAuthorsValues(ctx context.Context, arg FacetAuthorsValuesParams) ([]FacetAuthorsValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetAuthorsValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetAuthorsValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetAuthorsValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetEditionValues = `-- name: FacetEditionValues :many
|
||||
SELECT mi.edition::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.edition IS NOT NULL AND mi.edition != ''
|
||||
GROUP BY mi.edition
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetEditionValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetEditionValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetEditionValues(ctx context.Context, arg FacetEditionValuesParams) ([]FacetEditionValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetEditionValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetEditionValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetEditionValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetFormatGroupValues = `-- name: FacetFormatGroupValues :many
|
||||
SELECT mi.format_group::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.format_group IS NOT NULL AND mi.format_group != ''
|
||||
GROUP BY mi.format_group
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetFormatGroupValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetFormatGroupValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetFormatGroupValues(ctx context.Context, arg FacetFormatGroupValuesParams) ([]FacetFormatGroupValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetFormatGroupValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetFormatGroupValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetFormatGroupValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetGenreValues = `-- name: FacetGenreValues :many
|
||||
SELECT mi.genre::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.genre IS NOT NULL AND mi.genre != ''
|
||||
GROUP BY mi.genre
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetGenreValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetGenreValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetGenreValues(ctx context.Context, arg FacetGenreValuesParams) ([]FacetGenreValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetGenreValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetGenreValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetGenreValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetImprintValues = `-- name: FacetImprintValues :many
|
||||
SELECT mi.imprint::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.imprint IS NOT NULL AND mi.imprint != ''
|
||||
GROUP BY mi.imprint
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetImprintValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetImprintValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetImprintValues(ctx context.Context, arg FacetImprintValuesParams) ([]FacetImprintValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetImprintValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetImprintValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetImprintValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetLanguageValues = `-- name: FacetLanguageValues :many
|
||||
SELECT mi.language::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.language IS NOT NULL AND mi.language != ''
|
||||
GROUP BY mi.language
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetLanguageValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetLanguageValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetLanguageValues(ctx context.Context, arg FacetLanguageValuesParams) ([]FacetLanguageValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetLanguageValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetLanguageValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetLanguageValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetMangaTypeValues = `-- name: FacetMangaTypeValues :many
|
||||
SELECT mi.manga_type::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown'
|
||||
GROUP BY mi.manga_type
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetMangaTypeValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetMangaTypeValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetMangaTypeValues(ctx context.Context, arg FacetMangaTypeValuesParams) ([]FacetMangaTypeValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetMangaTypeValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetMangaTypeValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetMangaTypeValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetNarratorsValues = `-- name: FacetNarratorsValues :many
|
||||
SELECT n::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND n IS NOT NULL AND n != ''
|
||||
GROUP BY n
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetNarratorsValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetNarratorsValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetNarratorsValues(ctx context.Context, arg FacetNarratorsValuesParams) ([]FacetNarratorsValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetNarratorsValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetNarratorsValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetNarratorsValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetPublishersValues = `-- name: FacetPublishersValues :many
|
||||
SELECT p::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND p IS NOT NULL AND p != ''
|
||||
GROUP BY p
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetPublishersValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetPublishersValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetPublishersValues(ctx context.Context, arg FacetPublishersValuesParams) ([]FacetPublishersValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetPublishersValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetPublishersValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetPublishersValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetReadingDirectionValues = `-- name: FacetReadingDirectionValues :many
|
||||
SELECT mi.reading_direction::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
|
||||
GROUP BY mi.reading_direction
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetReadingDirectionValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetReadingDirectionValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetReadingDirectionValues(ctx context.Context, arg FacetReadingDirectionValuesParams) ([]FacetReadingDirectionValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetReadingDirectionValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetReadingDirectionValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetReadingDirectionValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetSeriesValues = `-- name: FacetSeriesValues :many
|
||||
SELECT mi.series::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.series IS NOT NULL AND mi.series != ''
|
||||
GROUP BY mi.series
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetSeriesValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetSeriesValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetSeriesValues(ctx context.Context, arg FacetSeriesValuesParams) ([]FacetSeriesValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetSeriesValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetSeriesValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetSeriesValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetStoryArcValues = `-- name: FacetStoryArcValues :many
|
||||
SELECT mi.story_arc::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
|
||||
GROUP BY mi.story_arc
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetStoryArcValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetStoryArcValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetStoryArcValues(ctx context.Context, arg FacetStoryArcValuesParams) ([]FacetStoryArcValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetStoryArcValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetStoryArcValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetStoryArcValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FacetTagsValues = `-- name: FacetTagsValues :many
|
||||
SELECT t::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = $2
|
||||
AND t IS NOT NULL AND t != ''
|
||||
GROUP BY t
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type FacetTagsValuesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type FacetTagsValuesRow struct {
|
||||
Value string `db:"value" json:"value"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (q *Queries) FacetTagsValues(ctx context.Context, arg FacetTagsValuesParams) ([]FacetTagsValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, FacetTagsValues, arg.UserID, arg.LibraryID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []FacetTagsValuesRow{}
|
||||
for rows.Next() {
|
||||
var i FacetTagsValuesRow
|
||||
if err := rows.Scan(&i.Value, &i.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const FindHashConflictGroups = `-- name: FindHashConflictGroups :many
|
||||
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||
FROM media_items
|
||||
@@ -13257,44 +13913,69 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||
WHERE word_similarity($9, narrator) > 0.3
|
||||
))
|
||||
-- Facet registry Batch A additions (services/facet_registry.go is the
|
||||
-- Go-side source of truth for these params). Comma = OR-within for
|
||||
-- every text facet; scalar-fuzzy uses word_similarity per value,
|
||||
-- exact enums use ANY(), bools are presence-bound (NULL = unset).
|
||||
AND ($10 = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array($10, ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
||||
))
|
||||
AND ($11 = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array($11, ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
||||
))
|
||||
AND ($12 = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array($12, ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
||||
))
|
||||
AND ($13 = '' OR mi.age_rating = ANY(string_to_array($13, ',')))
|
||||
AND ($14 = '' OR mi.format_group = ANY(string_to_array($14, ',')))
|
||||
AND ($15 = '' OR mi.manga_type = ANY(string_to_array($15, ',')))
|
||||
AND ($16 = '' OR mi.reading_direction = ANY(string_to_array($16, ',')))
|
||||
AND ($17::bool IS NULL OR mi.explicit = $17::bool)
|
||||
AND ($18::bool IS NULL OR mi.abridged = $18::bool)
|
||||
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||
AND (
|
||||
$10 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) >= $10 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year >= $10)
|
||||
$19 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) >= $19 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year >= $19)
|
||||
)
|
||||
AND (
|
||||
$11 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) <= $11 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year <= $11)
|
||||
$20 = 0 OR
|
||||
EXTRACT(YEAR FROM mi.date_published) <= $20 OR
|
||||
(mi.date_published IS NULL AND mi.copyright_year <= $20)
|
||||
)
|
||||
-- Boolean (exact)
|
||||
AND (
|
||||
$12::bool IS NULL OR -- Not specified = show all
|
||||
($12::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
||||
($12::bool IS FALSE AND mi.cover_image_path IS NULL)
|
||||
$21::bool IS NULL OR -- Not specified = show all
|
||||
($21::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
||||
($21::bool IS FALSE AND mi.cover_image_path IS NULL)
|
||||
)
|
||||
-- Search query (fuzzy or exact based on quotes)
|
||||
AND (
|
||||
$13 = '' OR
|
||||
$22 = '' OR
|
||||
-- Fuzzy search (default)
|
||||
$14 = false AND (
|
||||
word_similarity($13, mi.title) > 0.3 OR
|
||||
word_similarity($13, COALESCE(mi.author, '')) > 0.3 OR
|
||||
word_similarity($13, COALESCE(mi.series, '')) > 0.3 OR
|
||||
$23 = false AND (
|
||||
word_similarity($22, mi.title) > 0.3 OR
|
||||
word_similarity($22, COALESCE(mi.author, '')) > 0.3 OR
|
||||
word_similarity($22, COALESCE(mi.series, '')) > 0.3 OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
||||
WHERE word_similarity($13, tag) > 0.3
|
||||
WHERE word_similarity($22, tag) > 0.3
|
||||
LIMIT 1
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
||||
WHERE word_similarity($13, contributor) > 0.3
|
||||
WHERE word_similarity($22, contributor) > 0.3
|
||||
LIMIT 1
|
||||
) OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||
WHERE word_similarity($13, narrator) > 0.3
|
||||
WHERE word_similarity($22, narrator) > 0.3
|
||||
LIMIT 1
|
||||
)
|
||||
) OR
|
||||
@@ -13308,25 +13989,25 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
||||
-- )
|
||||
-- Exact search (with quotes) - true exact match, not substring
|
||||
$14 = true AND (
|
||||
mi.title = $13 OR
|
||||
COALESCE(mi.author, '') = $13 OR
|
||||
COALESCE(mi.series, '') = $13 OR
|
||||
$13 = ANY(mi.tags_search) OR
|
||||
$13 = ANY(mi.contributors_search) OR
|
||||
$13 = ANY(mi.narrators_search)
|
||||
$23 = true AND (
|
||||
mi.title = $22 OR
|
||||
COALESCE(mi.author, '') = $22 OR
|
||||
COALESCE(mi.series, '') = $22 OR
|
||||
$22 = ANY(mi.tags_search) OR
|
||||
$22 = ANY(mi.contributors_search) OR
|
||||
$22 = ANY(mi.narrators_search)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
-- Primary sort: relevance score when searching
|
||||
CASE
|
||||
WHEN $13 != '' THEN
|
||||
WHEN $22 != '' THEN
|
||||
GREATEST(
|
||||
CASE WHEN $14 = false THEN
|
||||
word_similarity($13, mi.title)
|
||||
CASE WHEN $23 = false THEN
|
||||
word_similarity($22, mi.title)
|
||||
ELSE 0 END,
|
||||
CASE WHEN $14 = false THEN
|
||||
word_similarity($13, COALESCE(mi.author, ''))
|
||||
CASE WHEN $23 = false THEN
|
||||
word_similarity($22, COALESCE(mi.author, ''))
|
||||
ELSE 0 END,
|
||||
word_similarity($3, COALESCE(mi.author, '')),
|
||||
word_similarity($5, COALESCE(mi.genre, '')),
|
||||
@@ -13338,60 +14019,69 @@ ORDER BY
|
||||
END DESC,
|
||||
-- Secondary sort: user-specified sort parameter
|
||||
CASE
|
||||
WHEN $15 = 'title ASC' THEN mi.title
|
||||
WHEN $24 = 'title ASC' THEN mi.title
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $15 = 'title DESC' THEN mi.title
|
||||
WHEN $24 = 'title DESC' THEN mi.title
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $15 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
WHEN $24 = 'author ASC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $15 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
WHEN $24 = 'author DESC' THEN COALESCE(mi.author, '')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $15 = 'created_at ASC' THEN mi.created_at
|
||||
WHEN $24 = 'created_at ASC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $15 = 'created_at DESC' THEN mi.created_at
|
||||
WHEN $24 = 'created_at DESC' THEN mi.created_at
|
||||
ELSE '1970-01-01'::timestamp
|
||||
END DESC,
|
||||
CASE
|
||||
WHEN $15 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
WHEN $24 = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END ASC,
|
||||
CASE
|
||||
WHEN $15 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
WHEN $24 = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
||||
ELSE ''
|
||||
END DESC,
|
||||
-- Tertiary sort: title (default fallback)
|
||||
mi.title ASC
|
||||
LIMIT $17 OFFSET $16
|
||||
LIMIT $26 OFFSET $25
|
||||
`
|
||||
|
||||
type SearchMediaItemsUnifiedParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
||||
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
AuthorFilter interface{} `db:"author_filter" json:"author_filter"`
|
||||
SeriesFilter interface{} `db:"series_filter" json:"series_filter"`
|
||||
GenreFilter interface{} `db:"genre_filter" json:"genre_filter"`
|
||||
PublisherFilter interface{} `db:"publisher_filter" json:"publisher_filter"`
|
||||
LanguageFilter interface{} `db:"language_filter" json:"language_filter"`
|
||||
TagsFilter interface{} `db:"tags_filter" json:"tags_filter"`
|
||||
NarratorFilter interface{} `db:"narrator_filter" json:"narrator_filter"`
|
||||
StoryArcFilter interface{} `db:"story_arc_filter" json:"story_arc_filter"`
|
||||
ImprintFilter interface{} `db:"imprint_filter" json:"imprint_filter"`
|
||||
EditionFilter interface{} `db:"edition_filter" json:"edition_filter"`
|
||||
AgeRatingFilter interface{} `db:"age_rating_filter" json:"age_rating_filter"`
|
||||
FormatGroupFilter interface{} `db:"format_group_filter" json:"format_group_filter"`
|
||||
MangaTypeFilter interface{} `db:"manga_type_filter" json:"manga_type_filter"`
|
||||
ReadingDirectionFilter interface{} `db:"reading_direction_filter" json:"reading_direction_filter"`
|
||||
ExplicitFilter pgtype.Bool `db:"explicit_filter" json:"explicit_filter"`
|
||||
AbridgedFilter pgtype.Bool `db:"abridged_filter" json:"abridged_filter"`
|
||||
YearMin interface{} `db:"year_min" json:"year_min"`
|
||||
YearMax interface{} `db:"year_max" json:"year_max"`
|
||||
HasCover pgtype.Bool `db:"has_cover" json:"has_cover"`
|
||||
SearchQuery interface{} `db:"search_query" json:"search_query"`
|
||||
IsExactSearch interface{} `db:"is_exact_search" json:"is_exact_search"`
|
||||
Sort interface{} `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type SearchMediaItemsUnifiedRow struct {
|
||||
@@ -13464,6 +14154,15 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
|
||||
arg.LanguageFilter,
|
||||
arg.TagsFilter,
|
||||
arg.NarratorFilter,
|
||||
arg.StoryArcFilter,
|
||||
arg.ImprintFilter,
|
||||
arg.EditionFilter,
|
||||
arg.AgeRatingFilter,
|
||||
arg.FormatGroupFilter,
|
||||
arg.MangaTypeFilter,
|
||||
arg.ReadingDirectionFilter,
|
||||
arg.ExplicitFilter,
|
||||
arg.AbridgedFilter,
|
||||
arg.YearMin,
|
||||
arg.YearMax,
|
||||
arg.HasCover,
|
||||
|
||||
@@ -718,6 +718,31 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
||||
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
||||
))
|
||||
-- Facet registry Batch A additions (services/facet_registry.go is the
|
||||
-- Go-side source of truth for these params). Comma = OR-within for
|
||||
-- every text facet; scalar-fuzzy uses word_similarity per value,
|
||||
-- exact enums use ANY(), bools are presence-bound (NULL = unset).
|
||||
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
||||
))
|
||||
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
||||
))
|
||||
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
||||
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
||||
))
|
||||
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
||||
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
||||
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
||||
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
||||
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
||||
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
||||
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
||||
AND (
|
||||
sqlc.narg('year_min') = 0 OR
|
||||
@@ -944,6 +969,214 @@ GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.lan
|
||||
ORDER BY score DESC, count DESC
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- Facet-value listings (faceted-navigation Batch C): top values + counts
|
||||
-- for the filter panel, one static query per media_items column (same
|
||||
-- sqlc-compatibility constraint as the Search*Values autocomplete above).
|
||||
-- Visibility: same family-lens join as the search query — a user's
|
||||
-- hidden-library rows are absent, and the handler 403s an out-of-set
|
||||
-- explicit library_id. Counts are v1 global-per-library (not
|
||||
-- recomputed under the other active filters).
|
||||
|
||||
-- name: FacetAuthorsValues :many
|
||||
SELECT a::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND a IS NOT NULL AND a != ''
|
||||
GROUP BY a
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetPublishersValues :many
|
||||
SELECT p::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND p IS NOT NULL AND p != ''
|
||||
GROUP BY p
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetNarratorsValues :many
|
||||
SELECT n::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND n IS NOT NULL AND n != ''
|
||||
GROUP BY n
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetTagsValues :many
|
||||
SELECT t::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND t IS NOT NULL AND t != ''
|
||||
GROUP BY t
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetSeriesValues :many
|
||||
SELECT mi.series::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.series IS NOT NULL AND mi.series != ''
|
||||
GROUP BY mi.series
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetGenreValues :many
|
||||
SELECT mi.genre::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.genre IS NOT NULL AND mi.genre != ''
|
||||
GROUP BY mi.genre
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetLanguageValues :many
|
||||
SELECT mi.language::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.language IS NOT NULL AND mi.language != ''
|
||||
GROUP BY mi.language
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetStoryArcValues :many
|
||||
SELECT mi.story_arc::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
|
||||
GROUP BY mi.story_arc
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetImprintValues :many
|
||||
SELECT mi.imprint::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.imprint IS NOT NULL AND mi.imprint != ''
|
||||
GROUP BY mi.imprint
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetEditionValues :many
|
||||
SELECT mi.edition::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.edition IS NOT NULL AND mi.edition != ''
|
||||
GROUP BY mi.edition
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetAgeRatingValues :many
|
||||
SELECT mi.age_rating::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
|
||||
GROUP BY mi.age_rating
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetFormatGroupValues :many
|
||||
SELECT mi.format_group::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.format_group IS NOT NULL AND mi.format_group != ''
|
||||
GROUP BY mi.format_group
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetMangaTypeValues :many
|
||||
SELECT mi.manga_type::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown'
|
||||
GROUP BY mi.manga_type
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: FacetReadingDirectionValues :many
|
||||
SELECT mi.reading_direction::text AS value, COUNT(*) AS count
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
|
||||
GROUP BY mi.reading_direction
|
||||
ORDER BY count DESC, value ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- Media Notes queries
|
||||
-- name: GetMediaNote :one
|
||||
SELECT * FROM media_notes WHERE id = $1;
|
||||
|
||||
Reference in new issue
Block a user