Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).
Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).
Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.
Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.
Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
279 lines
8.1 KiB
Go
279 lines
8.1 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 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"`
|
|
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
|
|
}
|
|
}
|
|
|
|
// parseSidecarAudioFields extracts the audio-only fields from a
|
|
// Libation/Audiobookshelf metadata.json into the payload (narrators and
|
|
// the curated chapter list).
|
|
func parseSidecarAudioFields(path string, data []byte) *AudioMetadataPayload {
|
|
var sidecar struct {
|
|
Narrators []string `json:"narrators"`
|
|
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 {
|
|
return nil
|
|
}
|
|
payload := &AudioMetadataPayload{
|
|
Narrators: sidecar.Narrators,
|
|
}
|
|
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
|
|
}
|