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 }