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+)?`) // ", Book N" (also ; – : separators), Audible/Amazon style. deriveSeriesRe = regexp.MustCompile(`(?i)^\s*(.+?)\s*[,;:–-]\s*book\s+(\d+(?:\.\d+)?)\s*$`) // Conservative "Title: , 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 ", 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: , 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", }