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
@@ -24,15 +24,20 @@ func NewLibraryService(db *database.Queries) *LibraryService {
|
||||
|
||||
// Library type definitions and file extensions
|
||||
const (
|
||||
LibraryTypeEbooks = "ebooks"
|
||||
LibraryTypeComics = "comics"
|
||||
LibraryTypeManga = "manga"
|
||||
LibraryTypeEbooks = "ebooks"
|
||||
LibraryTypeComics = "comics"
|
||||
LibraryTypeManga = "manga"
|
||||
LibraryTypeAudiobooks = "audiobooks"
|
||||
)
|
||||
|
||||
var AllowedExtensions = map[string][]string{
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
|
||||
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
// All four decided breadth formats registered from day one (the
|
||||
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
|
||||
// scans richly in Batch A — the chapter abstraction carries the rest.
|
||||
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
|
||||
}
|
||||
|
||||
var MimeTypes = map[string]string{
|
||||
@@ -62,6 +67,12 @@ var MimeTypes = map[string]string{
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".fb2": "application/x-fictionbook+xml",
|
||||
".pdb": "application/vnd.palm",
|
||||
// Audiobooks — audio/mp4 for the MP4-family containers is what
|
||||
// every platform decoder expects from an <audio>/ExoPlayer source.
|
||||
".m4b": "audio/mp4",
|
||||
".m4a": "audio/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".flac": "audio/flac",
|
||||
}
|
||||
|
||||
// GetLibraryTypes retrieves all available library types
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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
|
||||
}
|
||||
@@ -83,36 +83,36 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
|
||||
libUUID := uuid.New()
|
||||
|
||||
row := database.GetContinueSeriesItemsRow{
|
||||
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Full Book",
|
||||
Author: pgtype.Text{String: "Author", Valid: true},
|
||||
Isbn: pgtype.Text{String: "ISBN", Valid: true},
|
||||
Description: pgtype.Text{String: "Desc", Valid: true},
|
||||
FilePath: "/book.epub",
|
||||
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
|
||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
|
||||
Tags: []string{"tag1"},
|
||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
Edition: pgtype.Text{String: "1st", Valid: true},
|
||||
Genre: pgtype.Text{String: "Fiction", Valid: true},
|
||||
FormatGroup: "epub",
|
||||
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
IsReflowable: pgtype.Bool{Bool: true, Valid: true},
|
||||
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
|
||||
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
|
||||
ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
|
||||
MangaType: pgtype.Text{String: "manga", Valid: true},
|
||||
ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
|
||||
SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
|
||||
Volume: pgtype.Int4{Int32: 1, Valid: true},
|
||||
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
|
||||
FileSha256: pgtype.Text{String: "abc123", Valid: true},
|
||||
ID: pgtype.UUID{Bytes: itemUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Full Book",
|
||||
Author: pgtype.Text{String: "Author", Valid: true},
|
||||
Isbn: pgtype.Text{String: "ISBN", Valid: true},
|
||||
Description: pgtype.Text{String: "Desc", Valid: true},
|
||||
FilePath: "/book.epub",
|
||||
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
|
||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
|
||||
Tags: []string{"tag1"},
|
||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
Edition: pgtype.Text{String: "1st", Valid: true},
|
||||
Genre: pgtype.Text{String: "Fiction", Valid: true},
|
||||
FormatGroup: "epub",
|
||||
FormatMimetype: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
IsReflowable: pgtype.Bool{Bool: true, Valid: true},
|
||||
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
|
||||
TotalCharacters: pgtype.Int8{Int64: 500000, Valid: true},
|
||||
ChapterCount: pgtype.Int4{Int32: 20, Valid: true},
|
||||
MangaType: pgtype.Text{String: "manga", Valid: true},
|
||||
ReadingDirection: pgtype.Text{String: "rtl", Valid: true},
|
||||
SeriesCount: pgtype.Int4{Int32: 10, Valid: true},
|
||||
Volume: pgtype.Int4{Int32: 1, Valid: true},
|
||||
LibraryTypeName: pgtype.Text{String: "ebooks", Valid: true},
|
||||
FileSha256: pgtype.Text{String: "abc123", Valid: true},
|
||||
}
|
||||
|
||||
result := continueSeriesRowToMediaItems(row)
|
||||
|
||||
@@ -143,9 +143,9 @@ type UserSummaryStats struct {
|
||||
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
|
||||
MostActiveDay string `json:"most_active_day"` // date with the most minutes
|
||||
MostActiveMinutes int `json:"most_active_day_minutes"`
|
||||
TopBooks []TopBookStats `json:"top_books"`
|
||||
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
|
||||
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
|
||||
TopBooks []TopBookStats `json:"top_books"`
|
||||
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
|
||||
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
|
||||
}
|
||||
|
||||
type WeekSummaryStats struct {
|
||||
|
||||
@@ -73,8 +73,8 @@ func (j *Job) UpdateProgress(progress float64, filesScanned, newItems, errors in
|
||||
}
|
||||
|
||||
type JobResult struct {
|
||||
JobID string `json:"job_id"`
|
||||
Status JobStatus `json:"status"`
|
||||
JobID string `json:"job_id"`
|
||||
Status JobStatus `json:"status"`
|
||||
// LibraryID carries the scan job's library (from Params) so clients
|
||||
// can label progress rows after a page refresh, when the original
|
||||
// start-request context is gone.
|
||||
@@ -1026,4 +1026,3 @@ func (w *Worker) Shutdown() {
|
||||
close(w.jobQueue)
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user