Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).
Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
(narrators A2a precedent). Arrays are write truth; author/publisher
varchars become trigger-regenerated display strings
(sync_author_publisher_display) so every varchar consumer (trgm
search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
for playlist ordering.
Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.
Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.
Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
508 lines
17 KiB
Go
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), 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
|
|
}
|