diff --git a/internal/coverart/embedded.go b/internal/coverart/embedded.go new file mode 100644 index 00000000..ce00e731 --- /dev/null +++ b/internal/coverart/embedded.go @@ -0,0 +1,45 @@ +package coverart + +import ( + "errors" + "fmt" + "os" + + "github.com/dhowden/tag" +) + +// ErrNoEmbeddedArt indicates the audio file has no embedded picture. +// Distinct from "tag read failed" so the enricher can quietly fall +// through to the provider chain without logging at warn/error level. +var ErrNoEmbeddedArt = errors.New("coverart: no embedded album art") + +// ExtractEmbeddedAlbumArt reads the embedded picture from an audio +// file's tag block: ID3v2 APIC frames (MP3), FLAC +// METADATA_BLOCK_PICTURE blocks, MP4 covr atoms (M4A/M4B/AAC), and OGG +// METADATA_BLOCK_PICTURE base64 in vorbis comments. Whichever format +// dhowden/tag recognises. +// +// Returns ErrNoEmbeddedArt when the tag block parses but contains no +// usable picture; returns a wrapped error for I/O or tag-parse +// failures so callers can distinguish "no art" from "couldn't read". +// +// Picard- and Lidarr-tagged libraries typically carry the canonical +// art in this block already — extracting it beats every external API +// guess for accuracy and avoids a network round-trip. +func ExtractEmbeddedAlbumArt(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + defer func() { _ = f.Close() }() + + meta, err := tag.ReadFrom(f) + if err != nil { + return nil, fmt.Errorf("tag read: %w", err) + } + pic := meta.Picture() + if pic == nil || len(pic.Data) == 0 { + return nil, ErrNoEmbeddedArt + } + return pic.Data, nil +} diff --git a/internal/coverart/embedded_test.go b/internal/coverart/embedded_test.go new file mode 100644 index 00000000..a2243b58 --- /dev/null +++ b/internal/coverart/embedded_test.go @@ -0,0 +1,48 @@ +package coverart + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestExtractEmbeddedAlbumArt_NonexistentFile(t *testing.T) { + _, err := ExtractEmbeddedAlbumArt("/nonexistent/path/track.mp3") + if err == nil { + t.Fatal("err = nil, want non-nil for nonexistent file") + } + if errors.Is(err, ErrNoEmbeddedArt) { + t.Errorf("err is ErrNoEmbeddedArt; want a wrapped open error instead") + } +} + +func TestExtractEmbeddedAlbumArt_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.mp3") + if err := os.WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + _, err := ExtractEmbeddedAlbumArt(path) + if err == nil { + t.Fatal("err = nil, want non-nil for empty file (tag.ReadFrom should fail)") + } + if errors.Is(err, ErrNoEmbeddedArt) { + t.Errorf("err is ErrNoEmbeddedArt; want a wrapped tag-read error instead") + } +} + +func TestExtractEmbeddedAlbumArt_NonAudioFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "not-audio.mp3") + if err := os.WriteFile(path, []byte("this is not an audio file"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + _, err := ExtractEmbeddedAlbumArt(path) + if err == nil { + t.Fatal("err = nil, want non-nil for non-audio bytes") + } + if errors.Is(err, ErrNoEmbeddedArt) { + t.Errorf("err is ErrNoEmbeddedArt; want a wrapped tag-read error instead") + } +} diff --git a/internal/coverart/enricher.go b/internal/coverart/enricher.go index 73e7fc98..f45eafcf 100644 --- a/internal/coverart/enricher.go +++ b/internal/coverart/enricher.go @@ -89,6 +89,29 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error { } } + // Embedded layer: read the picture stored inside the audio file's + // tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr). + // Picard/Lidarr-tagged libraries typically carry the canonical art + // here, so this beats every external provider for accuracy and + // avoids the network round-trip. + if row.TrackFilePath != nil && *row.TrackFilePath != "" { + body, eerr := ExtractEmbeddedAlbumArt(*row.TrackFilePath) + if eerr == nil { + dest, werr := e.writeAlbumArt(albumID, *row.TrackFilePath, body) + if werr != nil { + e.logger.Error("coverart: embedded write failed; leaving NULL", + "album_id", uuidString(albumID), "err", werr) + return nil + } + return e.recordAlbumCover(ctx, albumID, dest, "embedded", currentVersion) + } + if !errors.Is(eerr, ErrNoEmbeddedArt) { + e.logger.Debug("coverart: embedded extract failed; falling through", + "album_id", uuidString(albumID), "err", eerr) + } + // No embedded art (or extract failed): continue to provider chain. + } + // Build the ref. Every provider receives it and decides whether it // can act on the fields available (MBID-only providers return // ErrNotFound when MBID is empty; name-based providers fall back to @@ -114,26 +137,10 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error { // crash. return e.recordAlbumCover(ctx, albumID, "", "none", currentVersion) } - albumDir := filepath.Dir(*row.TrackFilePath) - dest := filepath.Join(albumDir, "cover.jpg") - werr := os.WriteFile(dest, body, 0o644) - if werr != nil && e.DataDir != "" { - // Sidecar location isn't writable (e.g. RO music mount). - // Fall back to the managed cache so enrichment isn't - // gated on the library being writable. - fallbackDir := filepath.Join(e.DataDir, "album-art", uuidString(albumID)) - if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil { - e.logger.Error("coverart: managed-cache mkdir failed; leaving NULL", - "album_id", uuidString(albumID), "fallback_dir", fallbackDir, - "sidecar_err", werr, "mkdir_err", mkErr) - return nil - } - dest = filepath.Join(fallbackDir, "cover.jpg") - werr = os.WriteFile(dest, body, 0o644) - } + dest, werr := e.writeAlbumArt(albumID, *row.TrackFilePath, body) if werr != nil { e.logger.Error("coverart: write file failed; leaving NULL", - "album_id", uuidString(albumID), "path", dest, + "album_id", uuidString(albumID), "provider", provider.ID(), "err", werr) return nil } @@ -154,6 +161,32 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error { return nil // at least one transient failure; leave NULL for retry } +// writeAlbumArt persists art bytes for an album, preferring the sidecar +// location next to the track file. Falls back to the managed cache +// (/album-art//cover.jpg) when the sidecar location +// isn't writable (e.g. read-only music mount in a container) and +// DataDir is set. Returns the path that was actually written. +// +// Used by both the embedded-art layer and the external provider +// chain — both produce the same dest-and-fallback semantics. +func (e *Enricher) writeAlbumArt(albumID pgtype.UUID, trackFilePath string, body []byte) (string, error) { + albumDir := filepath.Dir(trackFilePath) + dest := filepath.Join(albumDir, "cover.jpg") + werr := os.WriteFile(dest, body, 0o644) + if werr != nil && e.DataDir != "" { + fallbackDir := filepath.Join(e.DataDir, "album-art", uuidString(albumID)) + if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil { + return "", fmt.Errorf("managed-cache mkdir failed (sidecar: %v): %w", werr, mkErr) + } + dest = filepath.Join(fallbackDir, "cover.jpg") + werr = os.WriteFile(dest, body, 0o644) + } + if werr != nil { + return "", fmt.Errorf("write cover: %w", werr) + } + return dest, nil +} + // recordAlbumCover writes the new cover_art_path / cover_art_source / // cover_art_sources_version atomically. func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, path, source string, version int32) error {