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.
199 lines
7.7 KiB
Go
199 lines
7.7 KiB
Go
package services
|
|
|
|
import "strings"
|
|
|
|
// 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"), 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 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",
|
|
"speculative fiction": "science fiction",
|
|
"detectives": "mystery", "detective": "mystery",
|
|
"whodunnit": "mystery", "whodunit": "mystery",
|
|
"adventure fiction": "action & adventure",
|
|
"action and adventure": "action & adventure",
|
|
"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",
|
|
}
|
|
|
|
// 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 &
|
|
// Adventure" — one Audible blob) into individual candidates.
|
|
func splitGenreCandidates(genres []string) []string {
|
|
var out []string
|
|
for _, g := range genres {
|
|
for _, part := range strings.Split(g, ",") {
|
|
for _, p := range strings.Split(part, ";") {
|
|
for _, piece := range strings.Split(p, "/") {
|
|
t := strings.TrimSpace(piece)
|
|
if t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)
|
|
direct := "" // first non-floor shelf, source order
|
|
mapped := "" // first subgenre-resolved shelf, source order
|
|
floor := "" // fiction / nonfiction, source order
|
|
for _, c := range all {
|
|
lower := normalizeGenreCandidate(c)
|
|
if lower == "" || genreDemoted[lower] {
|
|
continue
|
|
}
|
|
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 direct != "" {
|
|
return direct, all
|
|
}
|
|
if mapped != "" {
|
|
return mapped, all
|
|
}
|
|
return floor, all
|
|
}
|