Files
bookhoard/internal/services/series_parsing.go
T
John O'Keefe 7e4c97f639 feat(scanner): fix ABS sidecar series parsing; three-way series mapping; smart genre; language codes; title-derived series
Phase 2a of the metadata overhaul (plan §2.1-2.3, §2.5-2.9).

The headline bug: ABS writes sidecar series as an array of STRINGS
(["The Witcher #3", ...]); the parser only accepted {series, sequence}
OBJECT arrays, so every ABS-matched book silently lost its series (found
live: 9 sidecars, zero series ingested; only folder-structure Malazan
survived). parseSeriesCandidates now normalizes string arrays, object
arrays and Libation plain strings.

Three-way series assignment (plan §2.2):
- primary bibliographic series -> series/series_number columns
- additional real series (Drizzt: Icewind Dale #1 = Legend of Drizzt #4)
  -> alternate_info JSONB, comic-established shape
- ordering constructs ('The Witcher (Reading Order)') -> PlaylistSeries
  candidates; NEVER the series column. Detection is explicit-marker-only
  ((reading order|chronological|publication order|in order|timeline) as
  parenthetical/suffix) — precision over recall; a series merely
  containing 'timeline' mid-name stays a series.

Sequence policy: leading-number extraction ('Book 2' -> 2, '2,
Dramatized Adaptation' -> 2, '1a' -> 1); lossy extractions preserve the
raw text in alternate_info.series_sequence_raw.

Smart genre (deterministic lexicon): compound Audible blobs split
('Epic, Action & Adventure, Military, Sword & Sorcery' -> 4 candidates),
alias-normalized, format noise demoted ('Audiobook' never wins),
most-specific canonical genre takes the genre column, all candidates
stay in tags.

Language: display names normalize to ISO 639-1 on ingest; ROOT-CAUSED
the update-path drop — CreateMediaItem call site never set Language
(explicit NULL over the column default), the update path did; create
now carries it.

Title-derived series fallback (§2.7): 'Baptism of Fire: The Witcher,
Book 3' 2-level folders split conservatively (only when the post-colon
tail matches ', Book N'), subtitle parsed for series+number, blanks-only
precedence, matching-name number fill, zero display mutation. Works
with no sidecar and no network.

Ingest fixes: subtitle/explicit land in their columns (audio tag
subtitle stops joining into title); full authors[] array (was [0]-
only); sidecar descriptions sanitized via bluemonday UGC policy
(vendored).

metadata_overrides: verified scan-update path already applies
ApplyMetadataOverrides; subtitle/explicit/authors covered by the Phase 1
override adaptation.

Tests: new series_parsing_test.go (string-array sidecar e2e, Drizzt
alternate_info, lossy raw preservation, playlist classification,
title-derived fallback incl. no-mutation + blanks-only, genre lexicon,
language map); audio-tag subtitle pin updated to column behavior;
calibre/ABS 2.5-series_index pins widened per float change (changelog'd
in phase 1).

Deferred to 2b: auto-created playlist collections (§2.4) — settings
toggle, collection upsert, ownership.
2026-10-19 14:34:42 -04:00

236 lines
8.1 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 ""
}
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",
}