A2a (metadata round): narrators TEXT[] + narrators_search (tags pattern: accumulator + GIN pairs) + abridged bool on media_items; scanner fills from the Libation/ABS sidecar (narrators verbatim, abridged flag) with audio-payload promotion; editorial fields ride the real columns while audio_metadata stays scan-derived (duration/ codec/chapters). Detail/edit/save paths carry the fields; the web edit dialog grows an Audio section (Narrator(s) comma-input, Abridged toggle, scanned duration read-only); search weights + narrator filter (word_similarity over narrators_search) + narrator autocomplete added. A2c (per-media-type wording): copy helpers keyed on format_group — Listen Now, Mark Complete/Not Complete, Listening Progress, Your Listening Stats, Time Listening, Time listened (pages tile swapped for audio), Position H:MM:SS / total H:MM:SS on detail + progress pages (duration parsed from audio_metadata). Dashboard: system collections swap to Continue Listening / Recently Listened at DISPLAY time when the audiobooks library is selected (stored names unchanged; restores keep working); week card = Listening Time / Sessions / Books Touched; cover-corner badges identify audiobook (headphones) / comic / manga cards on mixed grids — books stay unbadged; hover action icon follows the media type. A2b (player): the scrubber binds value to the playhead with an explicit scrub guard (the handle previously froze at its initial max); header row = reader-style back arrow + Chapter n/total launcher; chapters open in a floating top-down drawer (current chapter highlighted, page never shifts); Skip/Speed rows share one label-left layout; honest wall-clock session accounting (playing-only deltas, paused flush). A2d (bookmarks): note-bearing time bookmarks ride the existing media_bookmarks substrate (notes column + sync-aware service already there) — audio locator convention audio:<seconds>, chapter_number filled, auto-title '<chapter> — H:MM:SS', capture popover with optional note, drawer with jump/edit-note/delete. Zero server changes needed. Verified live on the Libation sample: narrators backfilled by rescan; PUT edit persisted + overrides detected (narrators, abridged); force rescan preserved the user edit; reset-to-scanned-of-defaults restored truth; narrator filter/autocomplete scoped by library; dashboard listsening wording + badges render; bookmark save with note deduped to one row.
291 lines
8.6 KiB
Go
291 lines
8.6 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"`
|
|
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
|
|
}
|