package scanner import ( "context" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" ) // narratorSuffixRE matches a trailing "read by X" / "(Read by X)" / // "(UK Version: Read by X)" / "- read by X" pattern that some audiobook // taggers put in the title field. The narrator is already captured as // item_people kind=8 from the dedicated narrator tag, so removing this // noise yields a clean human-readable title without losing any data. // // The narrator body deliberately excludes the dash so titles like // "Series Name Read by Foo N - Book Title" don't get mistakenly // truncated (the "Read by Foo" there is part of the series name, not a // narrator credit). Real narrator suffixes never contain a `-` after the // "read by". var narratorSuffixRE = regexp.MustCompile(`(?i)\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*read by [A-Za-z0-9., '&]+\)?\s*$`) // unabridgedTokenRE matches a parenthesized "(unabridged)" anywhere in // the title (sometimes mid-string between series and book). Stripped // because it's a format marker, not part of the work's name. var unabridgedTokenRE = regexp.MustCompile(`(?i)\s*\(unabridged\)\s*`) // collapseSpacesRE squashes any runs of whitespace into a single space. // Used after the strip passes since removing a mid-string token can // leave double spaces behind. var collapseSpacesRE = regexp.MustCompile(`\s+`) var audiobookDedupeTitleTokenRE = regexp.MustCompile(`[^A-Za-z0-9]+`) var audiobookFilesystemTitleNumericIDRE = regexp.MustCompile(`\s*[\[(]\d+[\])]\s*$`) var audiobookFilesystemTitleNoiseSuffixRE = regexp.MustCompile(`(?i)(?:\s*[-_. ]+\s*)?(?:nmr|audio\s*book|audiobook|unabridged|abridged)\s*$`) // stripNarratorSuffix removes the narrator-suffix noise and "(unabridged)" // markers from a title. Returns the input unchanged when no match. // Kept in sync with the SQL `regexp_replace` used by migration 146 so // the scanner write path and one-shot backfill produce identical output. func stripNarratorSuffix(title string) string { cleaned := narratorSuffixRE.ReplaceAllString(title, "") cleaned = unabridgedTokenRE.ReplaceAllString(cleaned, " ") cleaned = collapseSpacesRE.ReplaceAllString(cleaned, " ") return strings.TrimSpace(cleaned) } func normalizeAudiobookDedupeTitle(title string) string { title = stripNarratorSuffix(title) title = audiobookDedupeTitleTokenRE.ReplaceAllString(title, " ") title = collapseSpacesRE.ReplaceAllString(title, " ") return strings.ToLower(strings.TrimSpace(title)) } func audiobookDedupeTitlesMatch(a, b string) bool { left := normalizeAudiobookDedupeTitle(a) right := normalizeAudiobookDedupeTitle(b) if left == "" || right == "" { return false } if left == right { return true } if len(left) < len(right) { return strings.HasPrefix(right, left+" ") } return strings.HasPrefix(left, right+" ") } // parsedAudiobook is the structured output of parseAudiobookFolder. // The scanner write path (Task 8) converts this into media_items + // media_files + item_people rows. type parsedAudiobook struct { Title string Author string Narrator string Series string SeriesPosition string Year int ASIN string Overview string Genres []string Publisher string ReleaseDate string Language string Files []parsedAudiobookFile } // parsedAudiobookFile is one audio file belonging to a parsed audiobook. // For single-file .m4b audiobooks there is exactly one entry; for // multi-file folders (Task 7) there is one per file. type parsedAudiobookFile struct { Path string Chapters []ChapterInfo Duration int // seconds Bitrate int // kbps CodecAudio string // aac, mp3, opus, flac Container string // m4b, mp3, mka, ... AudioChannels int } // parseAudiobookFolder reads a single audiobook folder and returns its // structured representation. Recognized layouts: // - one audio file in the folder, optionally with embedded chapters // - multiple audio files in the folder; each becomes its own // parsedAudiobookFile with a single synthesized chapter (title = // filename stem); metadata comes from the first file's tags // // Returns an error wrapping errFolderHasNoMedia when the folder contains zero // audio files, so the caller can skip it. Every other error (including an // ffprobe binary that cannot be executed) is a real failure and must be // reported by the caller. func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath string) (*parsedAudiobook, error) { entries, err := os.ReadDir(folderPath) if err != nil { if errors.Is(err, os.ErrNotExist) { // The folder was renamed or deleted between the scan walk and // this read. It holds no media now, so the caller skips it as it // would an empty folder rather than counting a scan failure. return nil, fmt.Errorf("audiobook folder %s: %w", folderPath, errFolderHasNoMedia) } return nil, fmt.Errorf("read audiobook folder %s: %w", folderPath, err) } var audioFiles []string for _, entry := range entries { if entry.IsDir() { continue } if SupportsAudioFile(entry.Name()) { audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name())) } } if len(audioFiles) == 0 { return nil, fmt.Errorf("audiobook folder %s: %w", folderPath, errFolderHasNoMedia) } sort.Strings(audioFiles) book := &parsedAudiobook{} if len(audioFiles) == 1 { probed, err := ProbeFile(ctx, ffprobePath, audioFiles[0]) if err != nil { return nil, fmt.Errorf("probe audiobook file %s: %w", audioFiles[0], err) } book.populateFromTags(probed.FormatTags) book.applyFilesystemFallbacks(folderPath, audioFiles) book.Files = []parsedAudiobookFile{{ Path: audioFiles[0], Chapters: probed.Chapters, Duration: probed.Duration, Bitrate: probed.Bitrate, CodecAudio: probed.CodecAudio, Container: probed.Container, AudioChannels: probed.AudioChannels, }} return book, nil } // Multi-file case: read header from the first file, synthesize one // chapter per file with title = filename stem. A 0..0 range means the // chapter duration is unknown; consumers should treat it as non-seekable. probedFirst, err := ProbeFile(ctx, ffprobePath, audioFiles[0]) if err != nil { return nil, fmt.Errorf("probe first audiobook file %s: %w", audioFiles[0], err) } book.populateFromTags(probedFirst.FormatTags) book.applyFilesystemFallbacks(folderPath, audioFiles) book.Files = make([]parsedAudiobookFile, 0, len(audioFiles)) for i, path := range audioFiles { stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) probed, err := ProbeFile(ctx, ffprobePath, path) if err != nil { return nil, fmt.Errorf("probe audiobook file %s: %w", path, err) } book.Files = append(book.Files, parsedAudiobookFile{ Path: path, Chapters: []ChapterInfo{{ Index: i, Title: stem, StartSeconds: 0, EndSeconds: float64(probed.Duration), }}, Duration: probed.Duration, Bitrate: probed.Bitrate, CodecAudio: probed.CodecAudio, Container: probed.Container, AudioChannels: probed.AudioChannels, }) } return book, nil } // populateFromTags fills the audiobook's header fields (Title, Author, // Narrator, Series, Year) from the ffprobe format tags. Tags are // lower-cased by normalizeFormatTags upstream. func (b *parsedAudiobook) populateFromTags(tags map[string]string) { b.Title = firstNonEmpty(tags["title"], tags["album"]) b.Author = firstNonEmpty(tags["album_artist"], tags["artist"], tags["composer"]) b.Narrator = firstNonEmpty(tags["narrator"], tags["performer"], tags["composer"]) // Note: do NOT fall back to tags["album"] for Series. In audiobook // tagging, `album` is the book title, not the series name — using it // as the fallback writes series_name = title for every book without // an explicit series tag, producing one fake singleton "series" per // title (~141k of them on this server before this fix). b.Series = firstNonEmpty(tags["series"], tags["mvnm"]) b.SeriesPosition = firstNonEmpty(tags["series-part"], tags["mvin"], tags["movement"]) b.ASIN = firstNonEmpty(tags["asin"], tags["audible_asin"], tags["com.audible.asin"]) b.Overview = firstNonEmpty(tags["description"], tags["summary"], tags["comment"], tags["©cmt"]) b.Publisher = firstNonEmpty(tags["publisher"], tags["label"], tags["©pub"]) b.ReleaseDate = firstNonEmpty(tags["releasedate"], tags["release_date"], tags["date"], tags["year"], tags["releasetime"]) b.Language = firstNonEmpty(tags["language"], tags["lang"]) if year := firstNonEmpty(tags["date"], tags["year"], tags["releasetime"]); year != "" { if y := parseTagYear(year); y > 0 { b.Year = y } } b.Genres = parseGenresFromTags(tags) } // parseGenresFromTags pulls genres from the `genre` tag (which is often // slash-separated like "Mystery, Thriller & Suspense/Suspense/Fiction") // plus tmp_Genre1..tmp_Genre5 sub-tags written by some audiobook taggers. // Returns a deduplicated list in input order. func parseGenresFromTags(tags map[string]string) []string { seen := make(map[string]struct{}) var out []string add := func(g string) { g = strings.TrimSpace(g) if g == "" { return } key := strings.ToLower(g) if _, ok := seen[key]; ok { return } seen[key] = struct{}{} out = append(out, g) } if raw := tags["genre"]; raw != "" { for _, part := range strings.Split(raw, "/") { for _, sub := range strings.Split(part, ",") { add(sub) } } } for i := 1; i <= 5; i++ { key := fmt.Sprintf("tmp_genre%d", i) if v := tags[key]; v != "" { add(v) } } return out } func (b *parsedAudiobook) applyFilesystemFallbacks(folderPath string, audioFiles []string) { if b == nil || strings.TrimSpace(b.Title) != "" { return } b.Title = deriveAudiobookTitleFromFilesystem(folderPath, audioFiles) } func deriveAudiobookTitleFromFilesystem(folderPath string, audioFiles []string) string { candidates := []string{filepath.Base(folderPath)} if len(audioFiles) == 1 { file := audioFiles[0] candidates = append(candidates, strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) } for _, candidate := range candidates { if cleaned := cleanAudiobookFilesystemTitle(candidate); cleaned != "" { return cleaned } } return "" } func cleanAudiobookFilesystemTitle(title string) string { cleaned := strings.TrimSpace(title) cleaned = audiobookFilesystemTitleNumericIDRE.ReplaceAllString(cleaned, "") cleaned = strings.NewReplacer(".", " ", "_", " ").Replace(cleaned) for { next := audiobookFilesystemTitleNoiseSuffixRE.ReplaceAllString(cleaned, "") if next == cleaned { break } cleaned = next } cleaned = collapseSpacesRE.ReplaceAllString(cleaned, " ") return strings.Trim(cleaned, " -_.") } // parseTagYear extracts a 4-digit year (e.g. 1900-9999) from a tag value // that may be a bare year ("2024"), an ISO date ("2024-05-23"), or a // padded form ("(2024)"). Returns 0 if no plausible year is found. func parseTagYear(s string) int { s = strings.TrimSpace(s) for i := 0; i+4 <= len(s); i++ { candidate := s[i : i+4] if isAllDigits(candidate) { year := 0 for _, c := range candidate { year = year*10 + int(c-'0') } if year >= 1900 && year <= 9999 { return year } } } return 0 } func isAllDigits(s string) bool { if s == "" { return false } for _, c := range s { if c < '0' || c > '9' { return false } } return true }