feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player
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.
This commit is contained in:
1 parent
a093529b80
commit
902cbe5915
20 files changed
+1425
-212
No files matched your search
@@ -89,6 +89,10 @@ type MediaMetadata struct {
|
||||
AlternateInfo string // JSONB string of alternate series info
|
||||
ScanInformation string // Scan information
|
||||
Summary string // Summary from ComicInfo.xml
|
||||
|
||||
// Audiobook fields (media_items.audio_metadata JSONB — only the
|
||||
// audio paths read it; see media_scanner_audio.go)
|
||||
AudioMeta *AudioMetadataPayload
|
||||
}
|
||||
|
||||
type HashInfo struct {
|
||||
@@ -774,6 +778,16 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
|
||||
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
|
||||
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
|
||||
// shape is the chapter abstraction every format writes).
|
||||
if isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
}
|
||||
|
||||
// Extract hash information during metadata extraction
|
||||
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
||||
if err != nil {
|
||||
@@ -952,6 +966,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
|
||||
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create media item: %v", err)
|
||||
@@ -1055,9 +1070,10 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
|
||||
}
|
||||
|
||||
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
|
||||
// metadata.json sidecar next to the media file. Only fields with a matching
|
||||
// media_items column are mapped; narrators, subtitle, explicit, abridged and
|
||||
// chapters are deliberately skipped. Returns nil when no sidecar exists.
|
||||
// metadata.json sidecar next to the media file. Common fields map to
|
||||
// media_items columns; the audio-only fields (narrators, curated chapters)
|
||||
// travel through the audio payload separately (readSidecarAudio). Returns
|
||||
// nil when no sidecar exists.
|
||||
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
|
||||
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
|
||||
@@ -1070,29 +1086,49 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Series is tolerant: ABS/Calibre sidecars write an ARRAY of
|
||||
// {series, sequence} objects; Libation writes a plain STRING (or
|
||||
// null). An array-typed field made Libation's whole sidecar fail to
|
||||
// parse and silently fall to folder-structure metadata (found by
|
||||
// the audiobook ingest).
|
||||
var sidecar struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Series []struct {
|
||||
Series string `json:"series"`
|
||||
Sequence string `json:"sequence"`
|
||||
} `json:"series"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []string `json:"tags"`
|
||||
PublishedYear *int `json:"publishedYear"`
|
||||
PublishedDate *string `json:"publishedDate"`
|
||||
Publisher *string `json:"publisher"`
|
||||
Description *string `json:"description"`
|
||||
ISBN *string `json:"isbn"`
|
||||
ASIN *string `json:"asin"`
|
||||
Language *string `json:"language"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Authors []string `json:"authors"`
|
||||
Series json.RawMessage `json:"series"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []string `json:"tags"`
|
||||
PublishedYear json.RawMessage `json:"publishedYear"`
|
||||
PublishedDate *string `json:"publishedDate"`
|
||||
Publisher *string `json:"publisher"`
|
||||
Description *string `json:"description"`
|
||||
ISBN *string `json:"isbn"`
|
||||
ASIN *string `json:"asin"`
|
||||
Language *string `json:"language"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &sidecar); err != nil {
|
||||
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var seriesName, seriesSequence string
|
||||
var seriesArr []struct {
|
||||
Series string `json:"series"`
|
||||
Sequence string `json:"sequence"`
|
||||
}
|
||||
if len(sidecar.Series) > 0 {
|
||||
trimmed := strings.TrimSpace(string(sidecar.Series))
|
||||
if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") {
|
||||
if err := json.Unmarshal(sidecar.Series, &seriesArr); err == nil && len(seriesArr) > 0 {
|
||||
seriesName = seriesArr[0].Series
|
||||
seriesSequence = seriesArr[0].Sequence
|
||||
}
|
||||
} else if trimmed != "null" && trimmed != "" {
|
||||
// Plain string form (Libation)
|
||||
seriesName = strings.Trim(trimmed, "\"")
|
||||
}
|
||||
}
|
||||
|
||||
metadata := &MediaMetadata{
|
||||
Title: strings.TrimSpace(sidecar.Title),
|
||||
}
|
||||
@@ -1104,9 +1140,9 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
if len(sidecar.Authors) > 0 {
|
||||
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
|
||||
}
|
||||
if len(sidecar.Series) > 0 {
|
||||
metadata.Series = strings.TrimSpace(sidecar.Series[0].Series)
|
||||
if index, err := strconv.ParseFloat(strings.TrimSpace(sidecar.Series[0].Sequence), 32); err == nil {
|
||||
if seriesName != "" {
|
||||
metadata.Series = strings.TrimSpace(seriesName)
|
||||
if index, err := strconv.ParseFloat(strings.TrimSpace(seriesSequence), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(index)
|
||||
}
|
||||
}
|
||||
@@ -1118,8 +1154,12 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
metadata.PublishDate = date
|
||||
}
|
||||
}
|
||||
if metadata.PublishDate.IsZero() && sidecar.PublishedYear != nil && *sidecar.PublishedYear > 0 {
|
||||
metadata.PublishDate = time.Date(*sidecar.PublishedYear, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if metadata.PublishDate.IsZero() {
|
||||
// publishedYear tolerates number AND string forms ("2009" from
|
||||
// Libation lives as a string).
|
||||
if pubYear := parseJSONInt(sidecar.PublishedYear); pubYear > 0 {
|
||||
metadata.PublishDate = time.Date(pubYear, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
if sidecar.Publisher != nil {
|
||||
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
|
||||
@@ -1140,8 +1180,6 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||
return metadata
|
||||
}
|
||||
|
||||
// extractAudiobookshelfSidecar-TMP-END
|
||||
|
||||
// mergeMetadata intelligently merges metadata from multiple sources
|
||||
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
||||
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename
|
||||
@@ -3100,6 +3138,39 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
metadata = &MediaMetadata{}
|
||||
}
|
||||
|
||||
// Rescans must RE-DERIVE audio facts, never preserve stale ones
|
||||
// (gotcha 39's principle applied to audio_metadata).
|
||||
if isAudioFile(path) {
|
||||
if metadata.AudioMeta == nil {
|
||||
metadata.AudioMeta = readSidecarAudio(path)
|
||||
}
|
||||
s.enrichAudioMetadata(path, metadata)
|
||||
}
|
||||
|
||||
// Folder-structure fallback, mirroring the CREATE path
|
||||
// (processMediaFile): the update path skipped it, so a force rescan
|
||||
// could clear series/author that a sidecar didn't carry (Libation
|
||||
// writes "series": null while the folder layout knows the series).
|
||||
for _, folder := range s.folders {
|
||||
if strings.HasPrefix(path, folder) {
|
||||
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
|
||||
if metadata.Title == "" && folderMetadata.Title != "" {
|
||||
metadata.Title = folderMetadata.Title
|
||||
}
|
||||
if metadata.Author == "" && folderMetadata.Author != "" {
|
||||
metadata.Author = folderMetadata.Author
|
||||
}
|
||||
if metadata.Series == "" && folderMetadata.Series != "" {
|
||||
metadata.Series = folderMetadata.Series
|
||||
}
|
||||
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
|
||||
metadata.SeriesNumber = folderMetadata.SeriesNumber
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize metadata fields
|
||||
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
||||
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
||||
@@ -3144,6 +3215,7 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
||||
AlternateInfo: alternateInfoBytes,
|
||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
}
|
||||
|
||||
// Keep user-customized fields, and keep the override set itself intact.
|
||||
@@ -3905,6 +3977,11 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
|
||||
return "fixed_layout", false, true
|
||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||
return "comic_archive", false, true
|
||||
case ".m4b", ".m4a", ".mp3", ".flac":
|
||||
// Audio is neither reflowable text nor fixed-layout pages; the
|
||||
// "audiobook" group routes readers to the audio player instead
|
||||
// of any document engine.
|
||||
return "audiobook", false, false
|
||||
default:
|
||||
return "unknown", false, false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user