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 and (later) folder-as-book // files both produce {index, title, start, end} entries, so the locator // {chapter, offset_seconds} and the /chapters endpoint never learn which // file model produced them. 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"` } // 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"` } func isAudioFile(path string) bool { switch strings.ToLower(filepath.Ext(path)) { case ".m4b", ".m4a", ".mp3", ".flac": return true } return false } // 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 }() type ffprobeReport struct { Format struct { Duration flexFloat `json:"duration"` BitRate flexFloat `json:"bit_rate"` } `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 } // 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 } } // 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 } // 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 }