Files
bookhoard/internal/services/media_scanner_audio.go
T
John O'Keefe 6216bd9bcf feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
2026-10-19 07:54:28 -04:00

508 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 joins the title Calibre-style, mirroring the ABS sidecar
// path — there is no separate subtitle column. Skip when the title
// already contains it (tools sometimes pre-join).
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
metadata.Title = metadata.Title + ": " + 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), 32); err == nil {
metadata.SeriesNumber = int32(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
}