package services import "strings" // Smart genre selection for sidecar/embedded metadata (A2c plan §2.5). // // 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: // // 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 var genreAliases = map[string]string{ "sci-fi": "science fiction", "scifi": "science fiction", "sf": "science fiction", "sff": "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", "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", } // 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. var genreDemoted = map[string]bool{ "audiobook": true, "unabridged": true, "abridged": true, "dramatization": true, "dramatized": true, "audio drama": 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 } // 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. func pickSmartGenre(genres []string) (genre string, all []string) { all = splitGenreCandidates(genres) best := "" bestLen := -1 for _, c := range all { lower := strings.ToLower(strings.TrimSpace(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) } } if best != "" { return best, 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 } } return "", all }