diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 0e03552..2b20766 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -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 { diff --git a/internal/services/media_scanner_metadata_test.go b/internal/services/media_scanner_metadata_test.go index 8fe353f..c044538 100644 --- a/internal/services/media_scanner_metadata_test.go +++ b/internal/services/media_scanner_metadata_test.go @@ -152,3 +152,81 @@ func TestParseOPFContentCalibreSeries(t *testing.T) { 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) + } + }) +}