Files
bookhoard/internal/services/series_parsing.go
T
John O'Keefe 7e586df48a 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.
2026-10-19 18:59:23 -04:00

266 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"encoding/json"
"regexp"
"strconv"
"strings"
)
// Series-metadata parsing helpers for the scanner (A2c overhaul).
//
// Sources disagree wildly on how a series and its ordinal are spelled:
// ABS sidecars write ["Name #3"] string arrays or [{series, sequence}]
// objects, Libation writes a bare string, Audible prefixes ordinals with
// "Book ", Calibre uses decimal indices. These helpers normalize all of
// them into candidates the scanner can rank and assign.
// SeriesCandidate is one parsed series membership from any source.
type SeriesCandidate struct {
Name string
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
Number float64 // leading number of Sequence (0 = none parsed)
HasNum bool
FromSidecar bool
}
// Playlist markers that identify ordering constructs rather than
// bibliographic series. Deliberately explicit: this classification routes
// entries away from the series column into curated collections, so a false
// positive creates user-visible objects. Prefer misses over false hits.
var playlistMarkers = []string{
"reading order",
"chronological order",
"publication order",
"in order",
"timeline",
}
var (
seriesSeqSuffixRe = regexp.MustCompile(`\s+#([^#\s]+)$`)
// Leading ordinal inside a sequence string: "3", "1.5", "Book 2",
// "2, Dramatized Adaptation" → 2. Mirrors ABS cleanSeriesSequence.
seriesLeadingNumRe = regexp.MustCompile(`\d+(?:\.\d+)?`)
// "<Series Name>, Book N" (also ; – : separators), Audible/Amazon style.
deriveSeriesRe = regexp.MustCompile(`(?i)^\s*(.+?)\s*[,;:–-]\s*book\s+(\d+(?:\.\d+)?)\s*$`)
// Conservative "Title: <name>, Book N" split — only when the post-colon
// text is itself a series-and-number tail.
titleSubtitleSplitRe = regexp.MustCompile(`(?i)^(.{1,200}?)\s*:\s*(.+,\s*book\s+\d+(?:\.\d+)?)$`)
whitespaceRe = regexp.MustCompile(`\s+`)
)
// splitSeriesString splits "Name #1a" into name + sequence, ABS
// parseSeriesString-compatible: the sequence is anything after the last
// " #" up to end-of-string, containing no whitespace or further '#'.
func splitSeriesString(s string) (name, sequence string) {
s = strings.TrimSpace(s)
loc := seriesSeqSuffixRe.FindStringSubmatchIndex(s)
if loc == nil {
return s, ""
}
return strings.TrimSpace(s[:loc[0]]), s[loc[2]:loc[3]]
}
// seriesSequenceNumber extracts the ordinal from a raw sequence string.
// Returns the number, whether one was found, and whether information was
// lost in the extraction (caller should preserve the raw text, e.g. "1a").
func seriesSequenceNumber(sequence string) (num float64, found, lossy bool) {
trimmed := strings.TrimSpace(sequence)
if trimmed == "" {
return 0, false, false
}
token := seriesLeadingNumRe.FindString(trimmed)
if token == "" {
return 0, false, false
}
num, err := strconv.ParseFloat(token, 64)
if err != nil {
return 0, false, false
}
// "1.5" tokenizes exactly (not lossy); "1a"/"Book 2" keep a raw tail.
return num, true, trimmed != token
}
// isPlaylistSeriesName reports whether the series name is an ordering
// construct ("The Witcher (Reading Order)") rather than a bibliography.
// Match discipline: the marker must be a parenthetical qualifier or a
// trailing phrase — a series merely CONTAINING the word "timeline" mid-name
// ("The Timeline of...") is a real series, not a playlist.
func isPlaylistSeriesName(name string) bool {
lower := strings.ToLower(strings.TrimSpace(name))
for _, m := range playlistMarkers {
if strings.HasSuffix(lower, m) {
return true
}
if open := strings.Index(lower, "("); open >= 0 {
if strings.Contains(lower[open:], m) {
return true
}
}
}
return false
}
// parseSeriesCandidates normalizes the sidecar `series` field, which is
// written as an array of STRINGS ("Name #3" — ABS abmetadata), an array of
// OBJECTS ({series, sequence} — some exports), or a plain JSON string
// (Libation), into candidates.
func parseSeriesCandidates(raw json.RawMessage) []SeriesCandidate {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil
}
var out []SeriesCandidate
add := func(name, sequence string, fromSidecar bool) {
name = strings.TrimSpace(name)
if name == "" {
return
}
num, found, _ := seriesSequenceNumber(sequence)
out = append(out, SeriesCandidate{
Name: name, Sequence: sequence, Number: num, HasNum: found,
FromSidecar: fromSidecar,
})
}
if strings.HasPrefix(trimmed, "[") {
// Array of objects first ({"series": ..., "sequence": ...}).
var objs []struct {
Series string `json:"series"`
Name string `json:"name"`
Sequence string `json:"sequence"`
}
if err := json.Unmarshal(raw, &objs); err == nil && len(objs) > 0 {
for _, o := range objs {
name := o.Series
if name == "" {
name = o.Name
}
add(name, o.Sequence, true)
}
return out
}
// Array of strings: ABS abmetadata form "Name #3".
var strs []string
if err := json.Unmarshal(raw, &strs); err == nil {
for _, s := range strs {
name, seq := splitSeriesString(s)
add(name, seq, true)
}
return out
}
return nil
}
// Plain JSON string (Libation): "Name" or "Name #3".
var single string
if err := json.Unmarshal(raw, &single); err == nil {
name, seq := splitSeriesString(single)
add(name, seq, true)
}
return out
}
// deriveSeriesFromText extracts "<series>, Book N" from a title/subtitle
// string (Audible/Amazon convention). Returns ok=false when the text is not
// obviously a series tail — this fallback must not guess.
func deriveSeriesFromText(text string) (name string, num float64, hasNum bool, ok bool) {
if strings.TrimSpace(text) == "" {
return "", 0, false, false
}
m := deriveSeriesRe.FindStringSubmatch(text)
if m == nil {
return "", 0, false, false
}
name = whitespaceRe.ReplaceAllString(strings.TrimSpace(m[1]), " ")
if name == "" || len(name) > 200 || strings.HasPrefix(strings.ToLower(name), "book ") {
return "", 0, false, false
}
num, err := strconv.ParseFloat(m[2], 64)
if err != nil {
return "", 0, false, false
}
return name, num, true, true
}
// splitTitleOnSubtitle splits "Title: <name>, Book N" into title +
// subtitle, but ONLY when the post-colon text is itself a series-and-number
// tail — arbitrary colons in titles are never touched.
func splitTitleOnSubtitle(folderTitle string) (title, subtitle string, split bool) {
m := titleSubtitleSplitRe.FindStringSubmatch(strings.TrimSpace(folderTitle))
if m == nil {
return folderTitle, "", false
}
return strings.TrimSpace(m[1]), strings.TrimSpace(m[2]), true
}
// normalizeLanguageCode maps sidecar/display language names to ISO 639-1
// codes (the media_items.language convention). Values already in code form
// pass through; unknown display names are kept verbatim when they fit the
// VARCHAR(10) column, else dropped (empty) — never truncate mid-word.
func normalizeLanguageCode(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if code, ok := languageNameToCode[strings.ToLower(v)]; ok {
return code
}
lower := strings.ToLower(v)
if len(lower) == 2 {
return lower // already a code (or close enough to be harmless)
}
if len(v) <= 10 {
return v
}
return ""
}
// languageCodeToName — canonical English display name per ISO code,
// mirroring languageNameToCode (server owns display; the column stores
// ISO 639-1).
var languageCodeToName = map[string]string{
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
"uk": "Ukrainian", "pl": "Polish", "cs": "Czech", "sk": "Slovak",
"sv": "Swedish", "no": "Norwegian", "da": "Danish", "fi": "Finnish",
"is": "Icelandic", "hu": "Hungarian", "ro": "Romanian", "bg": "Bulgarian",
"el": "Greek", "tr": "Turkish", "ar": "Arabic", "he": "Hebrew",
"fa": "Persian", "hi": "Hindi", "bn": "Bengali", "ur": "Urdu",
"th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay",
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "sw": "Swahili",
"af": "Afrikaans", "ca": "Catalan", "hr": "Croatian", "sr": "Serbian",
"sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian",
"ka": "Georgian", "hy": "Armenian", "cy": "Welsh", "ga": "Irish",
"eu": "Basque", "gl": "Galician",
}
// LanguageDisplayName renders a stored language value for DISPLAY.
// Returns "" when the value is not a known code — callers keep the raw
// value then (legacy verbatim rows like "Pig Latin" display as
// themselves).
func LanguageDisplayName(code string) string {
if name, ok := languageCodeToName[strings.ToLower(strings.TrimSpace(code))]; ok {
return name
}
return ""
}
var languageNameToCode = map[string]string{
"english": "en", "spanish": "es", "español": "es", "french": "fr",
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
"italiano": "it", "portuguese": "pt", "dutch": "nl", "russian": "ru",
"ukrainian": "uk", "polish": "pl", "czech": "cs", "slovak": "sk",
"swedish": "sv", "norwegian": "no", "danish": "da", "finnish": "fi",
"icelandic": "is", "hungarian": "hu", "romanian": "ro", "bulgarian": "bg",
"greek": "el", "turkish": "tr", "arabic": "ar", "hebrew": "he",
"persian": "fa", "hindi": "hi", "bengali": "bn", "urdu": "ur",
"thai": "th", "vietnamese": "vi", "indonesian": "id", "malay": "ms",
"japanese": "ja", "korean": "ko", "chinese": "zh", "mandarin": "zh",
"cantonese": "zh", "swahili": "sw", "afrikaans": "af", "catalan": "ca",
"croatian": "hr", "serbian": "sr", "slovenian": "sl", "lithuanian": "lt",
"latvian": "lv", "estonian": "et", "georgian": "ka", "armenian": "hy",
"welsh": "cy", "irish": "ga", "basque": "eu", "galician": "gl",
}