Files
bookhoard/internal/services/media_scanner_audio.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

506 lines
17 KiB
Go

package services
// Audiobook ingestion (handoff item 3, Batch A). The m4b path is
// SIDECAR-FIRST: Libation (and Audiobookshelf-compatible tools) write a
// metadata.json next to the file carrying narrators/series/ASIN/chapters;
// ffprobe is the fallback (and the filler) for duration, codec profile,
// bitrate and embedded chapters. ffprobe is an OPTIONAL dependency — when
// absent the book still ingests (filename/folder metadata, no chapters
// until a rescan runs with ffprobe present).
//
// The chapters array written into media_items.audio_metadata IS the
// format abstraction: embedded m4b chapters, folder-as-book files and
// zip-of-audio books all produce {index, title, start, end} entries, so
// the locator {chapter, offset_seconds} and the /chapters endpoint never
// learn which file model produced them.
//
// Multi-file books (layout "folder" | "zip", see
// media_scanner_audio_multifile.go) additionally carry a tracks array:
// one entry per playable file, with its global-seconds start/end and the
// path the /audio/:trackIndex streaming endpoint resolves. Single-file
// books leave layout and tracks empty — the <audio> element streams the
// whole file exactly as before.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// AudioChapter is the uniform chapter entry for every audiobook file model.
type AudioChapter struct {
Index int `json:"index"`
Title string `json:"title"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
// AudioTrack is one playable file of a multi-file audiobook. Path is the
// library-relative file path (layout "folder") or the in-zip entry name
// (layout "zip"); the /audio/:trackIndex endpoint resolves it to bytes.
// Start/End are cumulative global seconds across the book — the same
// coordinate system chapters, bookmarks and audio_offset_seconds use.
type AudioTrack struct {
Index int `json:"index"`
Path string `json:"path"`
Title string `json:"title,omitempty"`
Start float64 `json:"start"`
End float64 `json:"end"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
// Only the audio paths read it — ebook paths never do (the kobo_metadata
// precedent).
type AudioMetadataPayload struct {
Narrators []string `json:"narrators,omitempty"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
CodecProfile string `json:"codec_profile,omitempty"`
BitRate int64 `json:"bit_rate,omitempty"`
ASIN string `json:"asin,omitempty"`
Abridged *bool `json:"abridged,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
// Layout: "" = single file (the Batch A model), "folder" = a
// directory of track files, "zip" = a zip archive of tracks.
Layout string `json:"layout,omitempty"`
// Tracks: one entry per playable file for multi-file layouts. Empty
// for single-file books (the whole file IS the track).
Tracks []AudioTrack `json:"tracks,omitempty"`
}
func isAudioFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav":
return true
}
return false
}
// isAudioTrackFile reports whether a path is a PLAYABLE track of a
// multi-file book. Zips are book CONTAINERS (layout "zip"), never tracks
// of a folder book — a nested zip inside a book root must not join the
// track list, and pre-grouping one-track strays were never zips.
func isAudioTrackFile(path string) bool {
if strings.EqualFold(filepath.Ext(path), ".zip") {
return false
}
return isAudioFile(path)
}
// ffprobeAvailable is resolved once per scan process; a missing binary is
// a degradation, not an error.
var ffprobeAvailable = func() bool {
_, err := exec.LookPath("ffprobe")
return err == nil
}()
// ffmpegAvailable mirrors ffprobeAvailable for the embedded-cover
// extractor (ffprobe cannot write image streams; ffmpeg can).
var ffmpegAvailable = func() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}()
// extractEmbeddedAudioCover pulls the embedded cover art (mp3 APIC /
// m4b covr / flac picture) out of an audio file with one ffmpeg call —
// the attached picture presents itself as the file's only video stream.
// Writes outputBase+".cover.jpg" following the EPUB sidecar convention
// ("<book>.cover.jpg"). Returns "" when ffmpeg is absent, the file
// carries no picture, or anything fails — purely optional enrichment.
func extractEmbeddedAudioCover(inputPath, outputBase string) string {
if !ffmpegAvailable {
return ""
}
outPath := outputBase + ".cover.jpg"
cmd := exec.Command("ffmpeg",
"-y", "-v", "error",
"-i", inputPath,
"-map", "0:v:0",
"-frames:v", "1",
outPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
// "matches no streams" is the no-embedded-art case; stay quiet
// about it, log anything unexpected.
if len(out) > 0 && !strings.Contains(string(out), "matches no streams") {
fmt.Printf("ffmpeg cover extraction note for %s: %s\n", inputPath, strings.TrimSpace(string(out)))
}
return ""
}
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
_ = os.Remove(outPath)
return ""
}
return outPath
}
type ffprobeReport struct {
Format struct {
Duration flexFloat `json:"duration"`
BitRate flexFloat `json:"bit_rate"`
// Tags: ffprobe normalizes well-known atoms to lowercase keys but
// passes freeform keys (Audible's `----:com.apple.iTunes:*`)
// through verbatim — a map with case-insensitive lookup is the
// only honest shape. See tag().
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
SampleRate string `json:"sample_rate"`
} `json:"streams"`
Chapters []struct {
ID int `json:"id"`
StartTime flexFloat `json:"start_time"`
EndTime flexFloat `json:"end_time"`
Tags struct {
Title string `json:"title"`
} `json:"tags"`
} `json:"chapters"`
}
// flexFloat accepts JSON numbers OR number-strings: ffprobe's output
// switched to strings for chapter timings in the ffmpeg 8 build
// (Libation ingest exposed both shapes across formats).
type flexFloat float64
func (f *flexFloat) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(string(data))
s = strings.Trim(s, "\"")
if s == "" || s == "null" || s == "N/A" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
*f = flexFloat(v)
return nil
}
// runFFProbe shells out to ffprobe for the container facts the sidecar
// doesn't carry (duration/bitrate/codec). Returns nil when ffprobe is
// absent or the probe fails — callers degrade gracefully. Failures log:
// a silent nil made a real exec failure indistinguishable from a missing
// binary (found by the Libation ingest).
func runFFProbe(path string) *ffprobeReport {
if !ffprobeAvailable {
fmt.Printf("ffprobe unavailable (not on PATH); audio facts degrade to sidecar-only for %s\n", path)
return nil
}
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams", "-show_chapters",
path,
)
out, err := cmd.Output()
if err != nil {
fmt.Printf("ffprobe failed for %s: %v\n", path, err)
return nil
}
if len(out) == 0 {
fmt.Printf("ffprobe produced no output for %s\n", path)
return nil
}
var report ffprobeReport
if err := json.Unmarshal(out, &report); err != nil {
fmt.Printf("ffprobe output unparseable for %s: %v\n", path, err)
return nil
}
return &report
}
// tag returns the first non-empty ffprobe tag among the candidate keys,
// matched case-insensitively and trimmed. Empty string when none match.
func (r *ffprobeReport) tag(keys ...string) string {
if r == nil {
return ""
}
for _, key := range keys {
for k, v := range r.Format.Tags {
if strings.EqualFold(k, key) {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
}
}
return ""
}
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
// seeded) audio payload, creating it when no sidecar existed. Sidecar
// narrators/chapters win (curated); ffprobe fills duration, codec,
// bitrate, and chapters only when the sidecar carried none.
func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata) {
if metadata == nil {
return
}
payload := metadata.AudioMeta
if payload == nil {
payload = &AudioMetadataPayload{}
metadata.AudioMeta = payload
}
report := runFFProbe(path)
if report == nil {
fmt.Printf("Audio metadata for %s: sidecar only (ffprobe absent or failed)\n", path)
} else {
if payload.DurationSeconds == 0 && report.Format.Duration > 0 {
payload.DurationSeconds = float64(report.Format.Duration)
}
if payload.BitRate == 0 && report.Format.BitRate > 0 {
payload.BitRate = int64(report.Format.BitRate)
}
if payload.CodecProfile == "" && len(report.Streams) > 0 {
st := report.Streams[0]
profile := st.CodecName
if st.Profile != "" && st.Profile != "unknown" {
profile += " / " + st.Profile
}
payload.CodecProfile = profile
}
if len(payload.Chapters) == 0 {
for _, ch := range report.Chapters {
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: ch.ID,
Title: ch.Tags.Title,
Start: float64(ch.StartTime),
End: float64(ch.EndTime),
})
}
}
}
// The payload's ASIN mirrors the item column for the resolver; keep
// them in sync from whichever source set it.
if payload.ASIN == "" {
payload.ASIN = metadata.ASIN
}
// Promote the editorial facts (A2a columns) when the sidecar's audio
// payload carries them and the common-field parse left them empty.
if len(metadata.Narrators) == 0 && len(payload.Narrators) > 0 {
metadata.Narrators = payload.Narrators
}
if payload.Abridged != nil {
metadata.Abridged = *payload.Abridged
}
// Tag fallbacks run LAST in the single-file chain (sidecars → folder
// structure handled by the caller → tags → filename): they only fill
// fields the earlier sources left empty.
applyAudioTagFallbacks(metadata, report)
// CUE sheets are the last chapter source: sidecar chapters and
// embedded container chapters (m4b) outrank them.
if len(payload.Chapters) == 0 {
if cueChapters := readCueChapters(path); len(cueChapters) > 0 {
payload.Chapters = cueChapters
if payload.DurationSeconds == 0 {
if last := cueChapters[len(cueChapters)-1]; last.End > 0 {
payload.DurationSeconds = last.End
}
}
}
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
// Libation/Audiobookshelf metadata.json into the payload (narrators,
// abridged and the curated chapter list).
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
var sidecar struct {
Narrators []string `json:"narrators"`
Abridged *bool `json:"abridged"`
ASIN *string `json:"asin"`
Chapters []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
ID int `json:"id"`
} `json:"chapters"`
}
if err := json.Unmarshal(data, &sidecar); err != nil {
return nil
}
if len(sidecar.Narrators) == 0 && len(sidecar.Chapters) == 0 && sidecar.ASIN == nil && sidecar.Abridged == nil {
return nil
}
payload := &AudioMetadataPayload{
Narrators: sidecar.Narrators,
Abridged: sidecar.Abridged,
}
if sidecar.ASIN != nil {
payload.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
for i, ch := range sidecar.Chapters {
idx := ch.ID
if idx == 0 {
idx = i
}
payload.Chapters = append(payload.Chapters, AudioChapter{
Index: idx,
Title: strings.TrimSpace(ch.Title),
Start: ch.Start,
End: ch.End,
})
}
return payload
}
// readSidecarAudio is the sidecar hook used by the ABS metadata.json
// path: same file the common-field parser already read, audio fields only.
func readSidecarAudio(path string) *AudioMetadataPayload {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
data, err := os.ReadFile(jsonPath)
if err != nil {
return nil
}
return parseSidecarAudioFields(path, data)
}
// marshalAudioMetadata serializes the payload for the DB column; nil
// payload → nil bytes (NULL column).
func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
if payload == nil {
return nil
}
out, err := json.Marshal(payload)
if err != nil {
return nil
}
return out
}
// applyAudioTagFallbacks fills EMPTY media metadata from the file's own
// tags — the last rung of the ingestion precedence chain (curated
// sidecars → folder structure → embedded tags → filename). Field map:
//
// album / album_artist / artist → title / author
// composer → narrators (Audible's own
// convention: the narrator lives in the composer tag)
// grouping → series
// ----:com.apple.iTunes:SERIES[-PART] → series + index (Libation
// preserves these Audible freeform atoms)
// ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
// date → publish date; genre → genre
//
// Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence.
func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
if metadata == nil || report == nil {
return
}
if metadata.Title == "" {
if album := report.tag("album"); album != "" {
metadata.Title = album
}
}
// Subtitle is a first-class column (A2b) — no more Calibre-style title
// joining; the tool-provided subtitle lands in its own column.
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" {
metadata.Subtitle = subtitle
}
if metadata.Author == "" {
if artist := report.tag("album_artist"); artist != "" {
metadata.Author = artist
} else if artist := report.tag("artist"); artist != "" {
metadata.Author = artist
}
}
if len(metadata.Narrators) == 0 {
// Kept as ONE narrator string: composer values have no reliable
// multi-narrator delimiter, and splitting "Vance, Simon" would
// manufacture two fake people. Admins can edit the column.
if composer := report.tag("composer"); composer != "" {
metadata.Narrators = []string{composer}
if metadata.AudioMeta != nil && len(metadata.AudioMeta.Narrators) == 0 {
metadata.AudioMeta.Narrators = []string{composer}
}
}
}
if metadata.Series == "" {
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
metadata.Series = series
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
metadata.SeriesNumber = idx
}
}
}
}
if metadata.ASIN == "" {
if asin := report.tag("----:com.apple.itunes:asin", "asin"); asin != "" {
metadata.ASIN = asin
if metadata.AudioMeta != nil && metadata.AudioMeta.ASIN == "" {
metadata.AudioMeta.ASIN = asin
}
}
}
if metadata.AudioMeta == nil || metadata.AudioMeta.Abridged == nil {
if !metadata.Abridged {
switch strings.ToLower(report.tag("----:com.apple.itunes:abridged")) {
case "1", "true", "yes":
metadata.Abridged = true
if metadata.AudioMeta != nil {
abridged := true
metadata.AudioMeta.Abridged = &abridged
}
}
}
}
if metadata.PublishDate.IsZero() {
if date := report.tag("date"); date != "" {
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
if t, err := time.Parse(layout, strings.TrimSpace(date)); err == nil {
metadata.PublishDate = t
break
}
}
}
}
if metadata.Genre == "" {
metadata.Genre = report.tag("genre")
}
}
// audioDurationLabel renders the duration for logs/detail surfaces.
func audioDurationLabel(seconds float64) string {
if seconds <= 0 {
return "—"
}
d := time.Duration(seconds) * time.Second
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}
// parseJSONInt reads a RawMessage that may hold a JSON number or a
// number-string; 0 for anything else. Used for sidecar fields whose
// type differs across producing tools (Libation vs Calibre/ABS).
func parseJSONInt(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
trimmed := strings.TrimSpace(string(raw))
if v, err := strconv.Atoi(strings.Trim(trimmed, "\"")); err == nil {
return v
}
return 0
}