package artwork import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" ) var UserAgent = "intake/0.1 ( https://example.invalid )" var ( httpClient = &http.Client{Timeout: 20 * time.Second} mbMu sync.Mutex mbLastCall time.Time ) func mbWait(ctx context.Context) error { mbMu.Lock() defer mbMu.Unlock() if wait := time.Until(mbLastCall.Add(1100 * time.Millisecond)); wait > 0 { select { case <-time.After(wait): case <-ctx.Done(): return ctx.Err() } } mbLastCall = time.Now() return nil } func get(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept", "*/*") resp, err := httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } return io.ReadAll(io.LimitReader(resp.Body, 40<<20)) } func downloadToCache(ctx context.Context, rawURL, cacheDir string) (string, int64, error) { if cacheDir != "" { if err := os.MkdirAll(cacheDir, 0o755); err != nil { return "", 0, err } path := CachePath(cacheDir, rawURL) if info, err := os.Stat(path); err == nil && info.Size() > 0 { return path, info.Size(), nil } data, err := get(ctx, rawURL) if err != nil { return "", 0, err } if err := os.WriteFile(path, data, 0o644); err != nil { return "", 0, err } return path, int64(len(data)), nil } data, err := get(ctx, rawURL) if err != nil { return "", 0, err } f, err := os.CreateTemp("", "intake-art-*"+extForURL(rawURL)) if err != nil { return "", 0, err } if _, err := f.Write(data); err != nil { f.Close() return "", 0, err } f.Close() return f.Name(), int64(len(data)), nil } func extForURL(u string) string { if i := strings.IndexByte(u, '?'); i >= 0 { u = u[:i] } ext := strings.ToLower(filepath.Ext(u)) if imageExts[ext] { return ext } return ".jpg" } type mbRelease struct { ID string `json:"id"` Score int `json:"score"` Title string `json:"title"` Date string `json:"date"` Country string `json:"country"` Disambiguation string `json:"disambiguation"` } type mbSearch struct { Releases []mbRelease `json:"releases"` } func coverArtArchive(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) { query := fmt.Sprintf(`release:"%s" AND artist:"%s"`, album.Album, album.Artist) endpoint := "https://musicbrainz.org/ws/2/release/?query=" + url.QueryEscape(query) + "&fmt=json&limit=5" if err := mbWait(ctx); err != nil { return nil, err } body, err := get(ctx, endpoint) if err != nil { return nil, err } var parsed mbSearch if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("unreadable MusicBrainz response: %w", err) } if len(parsed.Releases) == 0 { return nil, nil } releases := parsed.Releases sortByYearMatch(releases, album.Year) var out []Candidate for _, rel := range releases { for _, size := range []string{"front-1200", "front-500"} { u := fmt.Sprintf("https://coverartarchive.org/release/%s/%s", rel.ID, size) path, sizeBytes, err := downloadToCache(ctx, u, opts.CacheDir) if err != nil { continue } label := "Cover Art Archive" if y := yearOf(rel.Date); y != "" { label += " · " + y } if rel.Country != "" { label += " · " + rel.Country } if rel.Disambiguation != "" { label += " · " + rel.Disambiguation } label += " · " + rel.Title c := Candidate{ Source: SourceCoverArt, Label: label, ProviderTitle: rel.Title, MatchScore: matchScore(rel.Title, album.Album), Path: path, URL: u, Bytes: sizeBytes, } if !fillDimensions(ctx, &c) { continue } out = append(out, c) break } if len(out) >= 2 { break } } return out, nil } func yearOf(date string) string { if len(date) >= 4 { for _, r := range date[:4] { if r < '0' || r > '9' { return "" } } return date[:4] } return "" } func sortByYearMatch(releases []mbRelease, want string) { score := func(r mbRelease) int { s := r.Score if want != "" && strings.HasPrefix(r.Date, want) { s += 100 } return s } for i := range releases { for j := i + 1; j < len(releases); j++ { if score(releases[j]) > score(releases[i]) { releases[i], releases[j] = releases[j], releases[i] } } } } type deezerSearch struct { Data []struct { ID int `json:"id"` Title string `json:"title"` Artist struct { Name string `json:"name"` } `json:"artist"` Cover string `json:"cover"` CoverXL string `json:"cover_xl"` CoverBig string `json:"cover_big"` NbTracks int `json:"nb_tracks"` ReleaseDate string `json:"release_date"` } `json:"data"` } func deezer(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) { q := url.QueryEscape(album.Artist + " " + album.Album) body, err := get(ctx, "https://api.deezer.com/search/album?q="+q+"&limit=10") if err != nil { return nil, err } var parsed deezerSearch if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("unreadable Deezer response: %w", err) } var out []Candidate for _, d := range parsed.Data { if !looseMatch(d.Artist.Name, album.Artist) || !looseMatch(d.Title, album.Album) { continue } score := matchScore(d.Title, album.Album) cover := d.CoverXL if cover == "" { cover = d.CoverBig } if cover == "" { cover = d.Cover } if cover == "" { continue } path, size, err := downloadToCache(ctx, cover, opts.CacheDir) if err != nil { continue } c := Candidate{ Source: SourceDeezer, Label: fmt.Sprintf("Deezer: %s - %s", d.Artist.Name, d.Title), ProviderTitle: d.Title, MatchScore: score, Path: path, URL: cover, Bytes: size, } if !fillDimensions(ctx, &c) { continue } out = append(out, c) if len(out) >= 2 { break } } return out, nil } type itunesSearch struct { Results []struct { CollectionName string `json:"collectionName"` ArtistName string `json:"artistName"` ArtworkURL100 string `json:"artworkUrl100"` TrackCount int `json:"trackCount"` ReleaseDate string `json:"releaseDate"` } `json:"results"` } func itunes(ctx context.Context, album AlbumInfo, opts Options) ([]Candidate, error) { q := url.QueryEscape(album.Artist + " " + album.Album) endpoint := fmt.Sprintf("https://itunes.apple.com/search?term=%s&entity=album&limit=10", q) body, err := get(ctx, endpoint) if err != nil { return nil, err } var parsed itunesSearch if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("unreadable iTunes response: %w", err) } var out []Candidate for _, r := range parsed.Results { if !looseMatch(r.ArtistName, album.Artist) || !looseMatch(r.CollectionName, album.Album) { continue } score := matchScore(r.CollectionName, album.Album) if r.ArtworkURL100 == "" { continue } big := upscaleArtwork(r.ArtworkURL100) path, size, err := downloadToCache(ctx, big, opts.CacheDir) if err != nil { path, size, err = downloadToCache(ctx, r.ArtworkURL100, opts.CacheDir) if err != nil { continue } big = r.ArtworkURL100 } c := Candidate{ Source: SourceITunes, Label: fmt.Sprintf("iTunes: %s - %s", r.ArtistName, r.CollectionName), ProviderTitle: r.CollectionName, MatchScore: score, Path: path, URL: big, Bytes: size, } if !fillDimensions(ctx, &c) { continue } out = append(out, c) if len(out) >= 2 { break } } return out, nil } func upscaleArtwork(u string) string { if u == "" { return u } slash := strings.LastIndexByte(u, '/') if slash < 0 { return u } prefix, last := u[:slash+1], u[slash+1:] if idx := strings.Index(last, "x"); idx > 0 { if rest := last[idx+1:]; strings.HasSuffix(rest, ".jpg") || strings.HasSuffix(rest, ".png") { suffix := strings.TrimSuffix(rest, ".jpg") suffix = strings.TrimSuffix(suffix, ".png") ext := ".jpg" if strings.HasSuffix(last, ".png") { ext = ".png" } name := suffix if i := strings.IndexFunc(suffix, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 { name = suffix[i:] } return prefix + "1400x1400" + name + ext } } return u } func matchScore(providerTitle, album string) int { a, b := sanitizeForMatch(providerTitle), sanitizeForMatch(album) switch { case a == "" || b == "": return 0 case a == b: return 3 case strings.Contains(a, b) || strings.Contains(b, a): return 2 default: return 1 } } func looseMatch(a, b string) bool { na, nb := sanitizeForMatch(a), sanitizeForMatch(b) if na == "" || nb == "" { return false } if na == nb { return true } return strings.Contains(na, nb) || strings.Contains(nb, na) } func escapeCaption(s string) string { replacer := strings.NewReplacer( "\\", "\\\\", "\n", " ", "%", "%%", ) return replacer.Replace(s) } func Placeholder(ctx context.Context, album AlbumInfo, outPath string, size int) error { if size <= 0 { size = 1000 } label := album.Album if album.Artist != "" { label = album.Artist + "\n" + album.Album } if label == "" { label = "no cover" } if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err } args := []string{ "-size", strconv.Itoa(size) + "x" + strconv.Itoa(size), "-background", "#2b2b2b", "-fill", "#e0e0e0", "-gravity", "center", "-size", strconv.Itoa(size*8/10) + "x" + strconv.Itoa(size*8/10), "caption:NO COVER ART\\n" + escapeCaption(label), "-gravity", "center", "-extent", strconv.Itoa(size) + "x" + strconv.Itoa(size), "-type", "TrueColor", "-quality", "88", outPath, } var errb bytes.Buffer cmd := execCommandContext(ctx, "magick", args...) cmd.Stderr = &errb if err := cmd.Run(); err != nil { return fmt.Errorf("generating placeholder: %s", strings.TrimSpace(errb.String())) } return nil }