package artwork import ( "context" "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strings" "intake/internal/audio" ) type Source string const ( SourceEmbedded Source = "embedded" SourceFolder Source = "folder" SourceCoverArt Source = "coverartarchive" SourceDeezer Source = "deezer" SourceITunes Source = "itunes" SourceLocalFolder Source = "local" SourcePlaceholder Source = "placeholder" ) type Candidate struct { Source Source Label string Path string URL string Width, Height int Bytes int64 Preferred bool EmbeddedCount int MatchScore int ProviderTitle string } func (c Candidate) ExactMatch() bool { return c.MatchScore >= 3 } func (c Candidate) Describe() string { dims := "unknown size" if c.Width > 0 && c.Height > 0 { dims = fmt.Sprintf("%dx%d", c.Width, c.Height) } return fmt.Sprintf("%s, %s", dims, humanBytes(c.Bytes)) } func (c Candidate) IsLowResolution() bool { if c.Width == 0 || c.Height == 0 { return false } return c.Width < 500 || c.Height < 500 } type Options struct { ExcludeHashes []string StagingDir string ArchiveDir string LocalCoverFolder string Sources []Source Offline bool CacheDir string Progress func(string) } func Discover(ctx context.Context, album AlbumInfo, tracks []audio.Track, opts Options) ([]Candidate, []string, error) { var ( out []Candidate warnings []string ) progress := opts.Progress if progress == nil { progress = func(string) {} } if embedded := embeddedCandidates(ctx, tracks); len(embedded) > 0 { out = append(out, embedded...) } for _, dir := range []string{opts.ArchiveDir, opts.StagingDir} { if dir == "" { continue } if found := folderCandidates(ctx, dir, opts.StagingDir); len(found) > 0 { out = append(out, found...) break } } if opts.LocalCoverFolder != "" { if found := localCandidates(ctx, opts.LocalCoverFolder, album); len(found) > 0 { out = append(out, found...) } } if !opts.Offline && enabled(opts.Sources, SourceCoverArt) { progress("querying MusicBrainz and Cover Art Archive ...") got, err := coverArtArchive(ctx, album, opts) if err != nil { warnings = append(warnings, fmt.Sprintf("cover art archive: %v", err)) } out = append(out, got...) } if !opts.Offline && enabled(opts.Sources, SourceDeezer) { progress("querying Deezer ...") got, err := deezer(ctx, album, opts) if err != nil { warnings = append(warnings, fmt.Sprintf("deezer: %v", err)) } out = append(out, got...) } if !opts.Offline && enabled(opts.Sources, SourceITunes) { progress("querying iTunes ...") got, err := itunes(ctx, album, opts) if err != nil { warnings = append(warnings, fmt.Sprintf("itunes: %v", err)) } out = append(out, got...) } if len(opts.ExcludeHashes) > 0 { excluded := map[string]bool{} for _, h := range opts.ExcludeHashes { excluded[h] = true } kept := out[:0] for _, c := range out { if h, err := hashFile(c.Path); err == nil && excluded[h] { continue } kept = append(kept, c) } out = kept } out = dedupe(out) sort.SliceStable(out, func(i, j int) bool { a, b := out[i], out[j] if a.Preferred != b.Preferred { return a.Preferred } ap, bp := a.Width*a.Height, b.Width*b.Height if ap != bp { return ap > bp } return a.Bytes > b.Bytes }) return out, warnings, nil } type AlbumInfo struct { Artist string Album string Year string Tracks int } func enabled(list []Source, s Source) bool { if len(list) == 0 { return true } for _, x := range list { if x == s { return true } } return false } func dedupe(in []Candidate) []Candidate { type kept struct { bytes string visual visual hashed bool } var ( out []Candidate vis []kept ) for _, c := range in { // Prefer the visual hash, which catches one picture at several sizes or // re-encoded by another provider. The byte hash is the fallback for an // image that cannot be decoded. dupe := -1 if vh, ok := visualHash(c.Path); ok { for i := range vis { if vis[i].hashed && vis[i].visual.near(vh) { dupe = i break } } if dupe < 0 { out = append(out, c) vis = append(vis, kept{visual: vh, hashed: true}) continue } } else { key, err := hashFile(c.Path) if err != nil { out = append(out, c) vis = append(vis, kept{}) continue } for i := range vis { if vis[i].bytes != "" && vis[i].bytes == key { dupe = i break } } if dupe < 0 { out = append(out, c) vis = append(vis, kept{bytes: key}) continue } } if len(c.Label) > len(out[dupe].Label) && out[dupe].Label == "" { out[dupe].Label = c.Label } if out[dupe].Source == SourcePlaceholder && c.Source != SourcePlaceholder { out[dupe] = c } } return out } func hashFile(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", err } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]), nil } func Hash(path string) (string, error) { return hashFile(path) } func CachePath(dir, url string) string { sum := sha256.Sum256([]byte(url)) ext := strings.ToLower(filepath.Ext(url)) if ext == "" || len(ext) > 5 { ext = ".jpg" } return filepath.Join(dir, hex.EncodeToString(sum[:16])+ext) } func humanBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for v := n / unit; v >= unit; v /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) }