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:
@@ -958,6 +958,88 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
|||||||
return metadata
|
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
|
// mergeMetadata intelligently merges metadata from multiple sources
|
||||||
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
||||||
// For comics: metadata.opf → ComicInfo.xml → 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) {
|
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
|
// Try Calibre sidecar first
|
||||||
calibreMetadata := s.extractCalibreSidecar(path)
|
calibreMetadata := s.extractCalibreSidecar(path)
|
||||||
if calibreMetadata != nil {
|
if calibreMetadata != nil {
|
||||||
@@ -1279,7 +1363,21 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
|||||||
return s.mergeMetadata(path, calibreMetadata)
|
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))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
|
|||||||
@@ -152,3 +152,81 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
|
|||||||
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
validate func(t *testing.T, m *MediaMetadata)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "full sidecar",
|
||||||
|
json: `{
|
||||||
|
"title": "An Book",
|
||||||
|
"authors": ["Author One", "Author Two"],
|
||||||
|
"series": [{"series": "The Series", "sequence": "4.5"}],
|
||||||
|
"genres": ["Fantasy"],
|
||||||
|
"tags": ["tag1"],
|
||||||
|
"publishedYear": 2019,
|
||||||
|
"publisher": "ACME Books",
|
||||||
|
"description": "A very good book.",
|
||||||
|
"isbn": "978-3-16-148410-0",
|
||||||
|
"asin": "B08XYZ",
|
||||||
|
"language": "en"
|
||||||
|
}`,
|
||||||
|
validate: func(t *testing.T, m *MediaMetadata) {
|
||||||
|
if m.Title != "An Book" || m.Author != "Author One" {
|
||||||
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||||
|
}
|
||||||
|
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||||
|
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||||
|
}
|
||||||
|
if len(m.Tags) != 2 {
|
||||||
|
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||||
|
}
|
||||||
|
if m.PublishDate.Year() != 2019 {
|
||||||
|
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
|
||||||
|
}
|
||||||
|
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
|
||||||
|
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
|
||||||
|
}
|
||||||
|
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
|
||||||
|
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sparse sidecar (real-world Audiobookshelf export)",
|
||||||
|
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
|
||||||
|
validate: func(t *testing.T, m *MediaMetadata) {
|
||||||
|
if m.Title != "Sparse (1234)" || m.Author != "X" {
|
||||||
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||||
|
}
|
||||||
|
if m.Description != "" || m.Tags != nil {
|
||||||
|
t.Error("null/empty sidecar fields must stay unset")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
|
||||||
|
}
|
||||||
|
tt.validate(t, m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no sidecar returns nil", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
|
||||||
|
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user