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.
266 lines
9.5 KiB
Go
266 lines
9.5 KiB
Go
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",
|
||
}
|