feat(scanner): read Audiobookshelf metadata.json sidecars

Libraries managed by Audiobookshelf keep a metadata.json next to each
book (title, authors, series+sequence, genres/tags, publisher,
description, isbn/asin, language, published year/date) - and no
metadata.opf. The scanner silently ignored those files: deleting them
changed nothing, and their data never reached the database.

Parse them as a first-class sidecar in extractMetadata, priority
metadata.opf -> metadata.json -> embedded media. Only fields with a
matching media_items column are mapped; narrators, subtitle, explicit,
abridged, and chapters are deliberately skipped.

Cover handling is unchanged: the existing findSidecarCover priority
(cover.jpg / folder.jpg / {basename}.jpg) applies to the sidecar branch
exactly as it does for Calibre.
This commit is contained in:
John O'Keefe
2026-09-12 17:49:46 -04:00
parent 61681aac23
commit 44b98f3fc3
2 changed files with 177 additions and 1 deletions
+99 -1
View File
@@ -958,6 +958,88 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
return metadata
}
// 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.
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(jsonPath)
if err != nil {
fmt.Printf("Warning: failed to read metadata.json sidecar for %s: %v\n", path, err)
return nil
}
var sidecar struct {
Title string `json:"title"`
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"`
}
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
}
metadata := &MediaMetadata{
Title: strings.TrimSpace(sidecar.Title),
}
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 {
metadata.SeriesNumber = int32(index)
}
}
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
metadata.Tags = utils.NormalizeTags(tags)
}
if sidecar.PublishedDate != nil {
if date, err := time.Parse("2006-01-02", strings.TrimSpace(*sidecar.PublishedDate)); err == nil {
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 sidecar.Publisher != nil {
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
}
if sidecar.Description != nil {
metadata.Description = strings.TrimSpace(*sidecar.Description)
}
if sidecar.ISBN != nil {
metadata.ISBN = utils.NormalizeISBNSafe(strings.TrimSpace(*sidecar.ISBN))
}
if sidecar.ASIN != nil {
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
}
if sidecar.Language != nil {
metadata.Language = strings.TrimSpace(*sidecar.Language)
}
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
@@ -1265,6 +1347,8 @@ func extractGenreTagsFromComicInfo(comicInfo *ComicInfo) []string {
}
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
// Metadata sidecar priority: Calibre metadata.opf, then Audiobookshelf
// metadata.json, then the media file's own embedded metadata.
// Try Calibre sidecar first
calibreMetadata := s.extractCalibreSidecar(path)
if calibreMetadata != nil {
@@ -1279,7 +1363,21 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
return s.mergeMetadata(path, calibreMetadata)
}
// EXISTING: Fallback to embedded metadata
// Audiobookshelf-style metadata.json sidecar (fields without a DB column
// - narrators, subtitle, explicit, abridged, chapters - are skipped)
abMetadata := extractAudiobookshelfSidecar(path)
if abMetadata != nil {
fmt.Printf("Using metadata.json sidecar for %s\n", path)
coverPath := findSidecarCover(path)
if coverPath != "" {
abMetadata.CoverPath = s.getRelativePath(coverPath)
}
return s.mergeMetadata(path, abMetadata)
}
// Fallback to embedded metadata
ext := strings.ToLower(filepath.Ext(path))
switch ext {