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
@@ -2,85 +2,128 @@ package services
|
||||
|
||||
import "strings"
|
||||
|
||||
// Smart genre selection for sidecar/embedded metadata (A2c plan §2.5).
|
||||
// Genre model v2 (faceted-navigation plan, Batch B): the `genre` column
|
||||
// holds ONLY top-level shelves; every more specific candidate is a tag
|
||||
// mapped to its parent shelf. User rule: *a subgenre is not a genre, it's
|
||||
// a tag*.
|
||||
//
|
||||
// Sources arrive as comma-blobs ("Epic, Action & Adventure, Military,
|
||||
// Sword & Sorcery" — one Audible genre string), media-format noise
|
||||
// ("Audiobook"), or maximally generic tags ("Fiction"). This is a
|
||||
// deterministic ranked rule set — no network, no guessing service:
|
||||
// ("Audiobook"), maximally generic tags ("Fiction"), or library-type
|
||||
// words ("Manga", "Comics" — ComicInfo files that describe the TYPE,
|
||||
// not a shelf). Deterministic ranked rule set — no network, no guessing:
|
||||
//
|
||||
// 1. split candidate strings on , ; /
|
||||
// 2. alias-map to canonical genres
|
||||
// 3. discard demoted (format/edition) tokens — they never win
|
||||
// 4. most specific (longest) canonical hit wins; "Fiction" is last resort
|
||||
// 5. every candidate (split parts included) still lands in tags, so
|
||||
// filtering keeps full coverage even when the genre column holds one
|
||||
// 2. alias-map variants to canonical forms (shelf or subgenre)
|
||||
// 3. discard demoted tokens (formats/editions/type-words) — never win
|
||||
// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre
|
||||
// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it)
|
||||
// 5. every candidate (split parts included) still lands in tags
|
||||
//
|
||||
// "adventure" and "action & adventure" are BOTH shelves — provider data
|
||||
// (Audible/Calibre) uses both, and the user-verified traces pin them:
|
||||
// {Pirates} → adventure (mapped), Malazan blob → action & adventure
|
||||
// (direct beats mapped). Merging them is deferred aliasing work.
|
||||
var genreShelves = map[string]bool{
|
||||
// Fiction side
|
||||
"fiction": true, "action & adventure": true, "adventure": true,
|
||||
"fantasy": true, "science fiction": true, "horror": true,
|
||||
"mystery": true, "thriller": true, "romance": true,
|
||||
"historical fiction": true, "western": true, "war & military": true,
|
||||
"young adult": true, "middle grade": true, "children's": true,
|
||||
"classics": true, "humor": true, "poetry": true, "short stories": true,
|
||||
// Nonfiction side
|
||||
"nonfiction": true, "biography & memoir": true, "history": true,
|
||||
"science": true, "true crime": true, "self-help": true,
|
||||
"philosophy": true, "business": true,
|
||||
}
|
||||
|
||||
// genreFloors are shelves that only win when NO other shelf candidate
|
||||
// exists (direct or mapped) — maximally generic positions. {Fiction,
|
||||
// Epic Fantasy} → fantasy, {Fiction} alone → fiction.
|
||||
func isGenreFloor(lower string) bool {
|
||||
return lower == "fiction" || lower == "nonfiction"
|
||||
}
|
||||
|
||||
// genreSubgenreShelf maps subgenre candidates (lowercase) to their parent
|
||||
// shelf. Anything here is a TAG by definition; its shelf wins the genre
|
||||
// column only when no direct shelf hit exists.
|
||||
var genreSubgenreShelf = map[string]string{
|
||||
// Fantasy subgenres
|
||||
"epic fantasy": "fantasy", "sword & sorcery": "fantasy",
|
||||
"dark fantasy": "fantasy", "urban fantasy": "fantasy",
|
||||
"paranormal": "fantasy", "high fantasy": "fantasy",
|
||||
"fairy tales": "fantasy", "mythology": "fantasy",
|
||||
"folklore": "fantasy", "litrpg": "fantasy",
|
||||
"progression fantasy": "fantasy",
|
||||
// Science fiction subgenres
|
||||
"space opera": "science fiction", "cyberpunk": "science fiction",
|
||||
"military science fiction": "science fiction", "dystopian": "science fiction",
|
||||
"post-apocalyptic": "science fiction", "apocalyptic": "science fiction",
|
||||
"time travel": "science fiction", "alternate history": "science fiction",
|
||||
"first contact": "science fiction", "steampunk": "science fiction",
|
||||
// Romance subgenres
|
||||
"contemporary romance": "romance", "historical romance": "romance",
|
||||
"paranormal romance": "romance",
|
||||
// Mystery subgenres
|
||||
"cozy mystery": "mystery", "crime": "mystery",
|
||||
"police procedural": "mystery", "noir": "mystery", "hardboiled": "mystery",
|
||||
// Thriller subgenres
|
||||
"suspense": "thriller", "espionage": "thriller",
|
||||
// Horror subgenres
|
||||
"gothic": "horror", "supernatural": "horror",
|
||||
// Fiction-side themes
|
||||
"literary fiction": "fiction", "coming of age": "fiction",
|
||||
"satire": "fiction", "anthology": "fiction", "alternate world": "fiction",
|
||||
// War & Military subgenres
|
||||
"military": "war & military", "war": "war & military",
|
||||
// Adventure subgenres
|
||||
"pirates": "adventure",
|
||||
// Biography & Memoir subgenres
|
||||
"biography": "biography & memoir", "autobiography": "biography & memoir",
|
||||
"memoir": "biography & memoir",
|
||||
// Remaining nonfiction topics → the nonfiction umbrella shelf
|
||||
"psychology": "nonfiction", "economics": "nonfiction",
|
||||
"politics": "nonfiction", "religion": "nonfiction",
|
||||
"spirituality": "nonfiction", "travel": "nonfiction",
|
||||
"cooking": "nonfiction", "food": "nonfiction",
|
||||
"health": "nonfiction", "fitness": "nonfiction",
|
||||
"sports": "nonfiction", "music": "nonfiction",
|
||||
"art": "nonfiction", "photography": "nonfiction",
|
||||
"education": "nonfiction", "reference": "nonfiction",
|
||||
"legal": "nonfiction", "medical": "nonfiction",
|
||||
}
|
||||
|
||||
// genreAliases maps provider variants to a canonical shelf OR subgenre
|
||||
// form (which the maps above then resolve).
|
||||
var genreAliases = map[string]string{
|
||||
"sci-fi": "science fiction", "scifi": "science fiction", "sf": "science fiction",
|
||||
"sff": "science fiction", "sci fi": "science fiction",
|
||||
"sci-fi": "science fiction", "scifi": "science fiction",
|
||||
"sf": "science fiction", "sff": "science fiction",
|
||||
"sci fi": "science fiction", "sci_fi": "science fiction",
|
||||
"speculative fiction": "science fiction",
|
||||
" detectives": "mystery", "detective": "mystery",
|
||||
"detectives": "mystery", "detective": "mystery",
|
||||
"whodunnit": "mystery", "whodunit": "mystery",
|
||||
"adventure fiction": "action & adventure",
|
||||
"action and adventure": "action & adventure",
|
||||
"ya": "young adult",
|
||||
"mg": "middle grade",
|
||||
"graphic novel": "graphic novels",
|
||||
"coming of age": "coming of age",
|
||||
"coming-of-age": "coming of age",
|
||||
"histfiction": "historical fiction",
|
||||
"epic": "epic fantasy",
|
||||
"high fantasy": "epic fantasy",
|
||||
"military scifi": "military science fiction",
|
||||
"military sf": "military science fiction",
|
||||
"space opera": "space opera",
|
||||
"post apocalyptic": "post-apocalyptic",
|
||||
"self help": "self-help",
|
||||
"sci_fi": "science fiction",
|
||||
"ya": "young adult", "mg": "middle grade",
|
||||
"coming-of-age": "coming of age",
|
||||
"histfiction": "historical fiction",
|
||||
"epic": "epic fantasy",
|
||||
"military scifi": "military science fiction",
|
||||
"military sf": "military science fiction",
|
||||
"post apocalyptic": "post-apocalyptic",
|
||||
"self help": "self-help",
|
||||
}
|
||||
|
||||
// canonicalGenres is the known-genre set (lowercase). A candidate that
|
||||
// normalizes to one of these wins the `genre` column; longest match beats
|
||||
// shorter (Sword & Sorcery > Fiction).
|
||||
var canonicalGenres = map[string]bool{
|
||||
"fiction": true, "nonfiction": true, "literary fiction": true,
|
||||
"classics": true, "poetry": true, "short stories": true,
|
||||
"anthology": true, "coming of age": true, "satire": true, "humor": true,
|
||||
"fantasy": true, "epic fantasy": true, "sword & sorcery": true,
|
||||
"dark fantasy": true, "urban fantasy": true, "paranormal": true,
|
||||
"paranormal romance": true, "high fantasy": true, "fairy tales": true,
|
||||
"mythology": true, "folklore": true,
|
||||
"science fiction": true, "space opera": true, "cyberpunk": true,
|
||||
"military science fiction": true, "dystopian": true,
|
||||
"post-apocalyptic": true, "apocalyptic": true, "time travel": true,
|
||||
"alternate history": true, "first contact": true, "steampunk": true,
|
||||
"litrpg": true, "progression fantasy": true,
|
||||
"historical fiction": true, "alternate world": true,
|
||||
"romance": true, "contemporary romance": true, "historical romance": true,
|
||||
"mystery": true, "cozy mystery": true, "crime": true, "true crime": true,
|
||||
"police procedural": true, "noir": true, "hardboiled": true,
|
||||
"thriller": true, "suspense": true, "espionage": true, "horror": true,
|
||||
"gothic": true, "supernatural": true,
|
||||
"action & adventure": true, "adventure": true, "western": true,
|
||||
"military": true, "war": true,
|
||||
"biography": true, "autobiography": true, "memoir": true,
|
||||
"history": true, "science": true, "philosophy": true,
|
||||
"psychology": true, "self-help": true, "business": true,
|
||||
"economics": true, "politics": true, "religion": true,
|
||||
"spirituality": true, "travel": true, "cooking": true, "food": true,
|
||||
"health": true, "fitness": true, "sports": true, "music": true,
|
||||
"art": true, "photography": true, "education": true, "reference": true,
|
||||
"young adult": true, "middle grade": true, "children's": true,
|
||||
"comics": true, "graphic novels": true, "manga": true,
|
||||
"legal": true, "medical": true,
|
||||
}
|
||||
|
||||
// genreDemoted never wins the genre column — media formats and editions,
|
||||
// not genres. They still pass through to tags.
|
||||
// genreDemoted never wins the genre column — media formats, editions,
|
||||
// and library-TYPE words (ComicInfo genre="Manga" describes the type,
|
||||
// not a shelf; shelves apply uniformly across all library types). They
|
||||
// still pass through to tags.
|
||||
var genreDemoted = map[string]bool{
|
||||
"audiobook": true, "unabridged": true, "abridged": true,
|
||||
"dramatization": true, "dramatized": true, "audio drama": true,
|
||||
"manga": true, "comics": true, "graphic novel": true,
|
||||
"graphic novels": true,
|
||||
}
|
||||
|
||||
// splitGenreCandidates splits compound genre strings ("Epic, Action &
|
||||
@@ -102,41 +145,54 @@ func splitGenreCandidates(genres []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// pickSmartGenre chooses the best genre from candidate strings. Returns the
|
||||
// winner ("" when there are no candidates) and the full candidate list for
|
||||
// the tags array.
|
||||
// normalizeGenreCandidate lowercases, trims, and alias-resolves one
|
||||
// candidate; "" means noise (empty input).
|
||||
func normalizeGenreCandidate(c string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(c))
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
if a, ok := genreAliases[lower]; ok {
|
||||
return a
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// pickSmartGenre chooses the SHELF for the genre column from candidate
|
||||
// strings (genre model v2). Returns the winner ("" when no shelf is
|
||||
// known — unknown-only input stays tag-only) and the full candidate
|
||||
// list for the tags array.
|
||||
func pickSmartGenre(genres []string) (genre string, all []string) {
|
||||
all = splitGenreCandidates(genres)
|
||||
best := ""
|
||||
bestLen := -1
|
||||
direct := "" // first non-floor shelf, source order
|
||||
mapped := "" // first subgenre-resolved shelf, source order
|
||||
floor := "" // fiction / nonfiction, source order
|
||||
for _, c := range all {
|
||||
lower := strings.ToLower(strings.TrimSpace(c))
|
||||
lower := normalizeGenreCandidate(c)
|
||||
if lower == "" || genreDemoted[lower] {
|
||||
continue
|
||||
}
|
||||
if genreAliases[lower] != "" {
|
||||
lower = genreAliases[lower]
|
||||
}
|
||||
if !canonicalGenres[lower] {
|
||||
continue
|
||||
}
|
||||
// Specificity: the longest canonical form wins; earlier candidates
|
||||
// win ties (source order is provider significance).
|
||||
if len(lower) > bestLen {
|
||||
best = lower
|
||||
bestLen = len(lower)
|
||||
switch {
|
||||
case genreShelves[lower]:
|
||||
if isGenreFloor(lower) {
|
||||
if floor == "" {
|
||||
floor = lower
|
||||
}
|
||||
} else if direct == "" {
|
||||
direct = lower
|
||||
}
|
||||
case genreSubgenreShelf[lower] != "":
|
||||
if mapped == "" {
|
||||
mapped = genreSubgenreShelf[lower]
|
||||
}
|
||||
}
|
||||
// Unknown candidates are tags-only — never guess a shelf.
|
||||
}
|
||||
if best != "" {
|
||||
return best, all
|
||||
if direct != "" {
|
||||
return direct, all
|
||||
}
|
||||
// No lexicon hit: fall back to the first non-demoted candidate verbatim
|
||||
// (display honesty over false taxonomy), "" when only noise existed.
|
||||
for _, c := range all {
|
||||
lower := strings.ToLower(strings.TrimSpace(c))
|
||||
if lower != "" && !genreDemoted[lower] {
|
||||
return strings.TrimSpace(c), all
|
||||
}
|
||||
if mapped != "" {
|
||||
return mapped, all
|
||||
}
|
||||
return "", all
|
||||
return floor, all
|
||||
}
|
||||
Reference in new issue
Block a user