package providers import ( "context" "fmt" "net/url" "strconv" "strings" ) // deezer uses Deezer's public catalogue API: no key, generous limits and // 1000px cover art. type deezer struct { r *Registry kind string // "album" or "song" } func (d *deezer) Name() string { return "deezer" } func (d *deezer) Label() string { return "Deezer" } type deezerErr struct { Error *struct { Message string `json:"message"` } `json:"error"` } // Deezer reports errors with a 200 status and an "error" object. func (e *deezerErr) check() error { if e.Error != nil { return fmt.Errorf("Deezer: %s", e.Error.Message) } return nil } func (d *deezer) get(ctx context.Context, path string, out any, e *deezerErr) error { if err := d.r.getJSON(ctx, "https://api.deezer.com"+path, nil, out); err != nil { return err } return e.check() } type deezerArtist struct { Name string `json:"name"` } type deezerAlbum struct { deezerErr ID int64 `json:"id"` Title string `json:"title"` Link string `json:"link"` CoverXL string `json:"cover_xl"` CoverBig string `json:"cover_big"` RecordType string `json:"record_type"` NbTracks int `json:"nb_tracks"` ReleaseDate string `json:"release_date"` Label string `json:"label"` Artist deezerArtist `json:"artist"` Genres struct { Data []struct { Name string `json:"name"` } `json:"data"` } `json:"genres"` } type deezerTrack struct { deezerErr ID int64 `json:"id"` Title string `json:"title"` Link string `json:"link"` Duration int `json:"duration"` ReleaseDate string `json:"release_date"` Artist deezerArtist `json:"artist"` Album deezerAlbum `json:"album"` } func cover(a deezerAlbum) string { if a.CoverXL != "" { return a.CoverXL } return a.CoverBig } func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) { q := url.Values{"q": {query}, "limit": {"25"}}.Encode() var out []Result if d.kind == "album" { var resp struct { deezerErr Data []deezerAlbum `json:"data"` } if err := d.get(ctx, "/search/album?"+q, &resp, &resp.deezerErr); err != nil { return nil, err } for _, a := range resp.Data { out = append(out, albumResult(a)) } return out, nil } var resp struct { deezerErr Data []deezerTrack `json:"data"` } if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil { return nil, err } for _, t := range resp.Data { out = append(out, trackResult(t)) } return out, nil } func albumResult(a deezerAlbum) Result { r := Result{ Provider: "deezer", ProviderID: "album:" + strconv.FormatInt(a.ID, 10), Title: a.Title, Subtitle: a.Artist.Name, ImageURL: cover(a), ExternalURL: a.Link, Extra: map[string]string{}, } if a.RecordType != "" && a.RecordType != "album" { r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:] } if a.NbTracks > 0 { r.Hint = fmt.Sprintf("%d tracks", a.NbTracks) } return r } func trackResult(t deezerTrack) Result { return Result{ Provider: "deezer", ProviderID: "track:" + strconv.FormatInt(t.ID, 10), Title: t.Title, Subtitle: t.Artist.Name, ImageURL: cover(t.Album), ExternalURL: t.Link, Hint: "From " + t.Album.Title, Extra: map[string]string{"album": t.Album.Title}, } } // Lookup refreshes one album or track by its Deezer ID. func (d *deezer) Lookup(ctx context.Context, providerID string) (Result, error) { kind, id, _ := strings.Cut(providerID, ":") if _, err := strconv.ParseInt(id, 10, 64); err != nil { return Result{}, ErrGone } switch kind { case "album": var a deezerAlbum if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil { return Result{}, deezerGone(err) } return applyAlbum(albumResult(a), a), nil case "track": var t deezerTrack if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil { return Result{}, deezerGone(err) } return applyTrack(trackResult(t), t), nil } return Result{}, ErrGone } // Deezer answers lookups of removed items with an error object. func deezerGone(err error) error { if strings.Contains(err.Error(), "no data") || strings.Contains(err.Error(), "not found") { return ErrGone } return err } func (d *deezer) Details(ctx context.Context, res Result) (Result, error) { if res.Provider != "deezer" { return res, nil } kind, id, _ := strings.Cut(res.ProviderID, ":") if res.Extra == nil { res.Extra = map[string]string{} } switch kind { case "album": var a deezerAlbum if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil { return res, err } return applyAlbum(res, a), nil case "track": var t deezerTrack if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil { return res, err } return applyTrack(res, t), nil } return res, nil } // applyAlbum adds the details only a full album lookup has. func applyAlbum(res Result, a deezerAlbum) Result { if res.Extra == nil { res.Extra = map[string]string{} } res.Year = yearOf(a.ReleaseDate) if a.Label != "" { res.Extra["label"] = a.Label } var g []string for _, x := range a.Genres.Data { g = append(g, x.Name) } if len(g) > 0 { res.Extra["genres"] = strings.Join(g, ", ") } return res } // applyTrack adds the details only a full track lookup has. func applyTrack(res Result, t deezerTrack) Result { if res.Extra == nil { res.Extra = map[string]string{} } res.Year = yearOf(t.ReleaseDate) if res.Year == "" { res.Year = yearOf(t.Album.ReleaseDate) } if t.Duration > 0 { res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60) } return res } // musicbrainz uses the MusicBrainz database with art from the Cover Art // Archive. Fully open data, but limited to one request per second. type musicbrainz struct { r *Registry kind string } func (m *musicbrainz) Name() string { return "musicbrainz" } func (m *musicbrainz) Label() string { return "MusicBrainz" } type mbCredit struct { Name string `json:"name"` JoinPhrase string `json:"joinphrase"` } func mbArtists(credits []mbCredit) string { var b strings.Builder for _, c := range credits { b.WriteString(c.Name) b.WriteString(c.JoinPhrase) } return strings.TrimSpace(b.String()) } type mbReleaseGroup struct { ID string `json:"id"` Title string `json:"title"` PrimaryType string `json:"primary-type"` SecondaryTypes []string `json:"secondary-types"` FirstReleaseDate string `json:"first-release-date"` ArtistCredit []mbCredit `json:"artist-credit"` } type mbRecording struct { ID string `json:"id"` Title string `json:"title"` Length int `json:"length"` FirstReleaseDate string `json:"first-release-date"` ArtistCredit []mbCredit `json:"artist-credit"` Releases []struct { Title string `json:"title"` ReleaseGroup struct { ID string `json:"id"` PrimaryType string `json:"primary-type"` } `json:"release-group"` } `json:"releases"` } func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) { var out []Result q := url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode() if m.kind == "album" { var resp struct { ReleaseGroups []mbReleaseGroup `json:"release-groups"` } if err := m.get(ctx, "/release-group?"+q, &resp); err != nil { return nil, err } for _, rg := range resp.ReleaseGroups { out = append(out, rgResult(rg)) } return out, nil } var resp struct { Recordings []mbRecording `json:"recordings"` } if err := m.get(ctx, "/recording?"+q, &resp); err != nil { return nil, err } for _, rec := range resp.Recordings { out = append(out, recResult(rec)) } return out, nil } // Lookup refreshes one release group (album) or recording (song). func (m *musicbrainz) Lookup(ctx context.Context, providerID string) (Result, error) { kind, id, _ := strings.Cut(providerID, ":") id = url.PathEscape(id) var err error var res Result switch kind { case "release-group": var rg mbReleaseGroup if err = m.get(ctx, "/release-group/"+id+"?inc=artist-credits&fmt=json", &rg); err == nil { res = rgResult(rg) } case "recording": var rec mbRecording if err = m.get(ctx, "/recording/"+id+"?inc=artist-credits+releases+release-groups&fmt=json", &rec); err == nil { res = recResult(rec) } default: return Result{}, ErrGone } if isStatus(err, 404) || isStatus(err, 400) { return Result{}, ErrGone } return res, err } // get waits for MusicBrainz's one-request-per-second limit, then fetches. func (m *musicbrainz) get(ctx context.Context, path string, out any) error { if err := m.r.mb.wait(ctx); err != nil { return err } return m.r.getJSON(ctx, "https://musicbrainz.org/ws/2"+path, nil, out) } func rgResult(rg mbReleaseGroup) Result { r := Result{ Provider: "musicbrainz", ProviderID: "release-group:" + rg.ID, Title: rg.Title, Subtitle: mbArtists(rg.ArtistCredit), Year: yearOf(rg.FirstReleaseDate), ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500", ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID, Extra: map[string]string{}, } // e.g. "Soundtrack", "Live", "EP · Soundtrack" types := rg.SecondaryTypes if rg.PrimaryType != "" && rg.PrimaryType != "Album" { types = append([]string{rg.PrimaryType}, types...) } if len(types) > 0 { r.Extra["type"] = strings.Join(types, " · ") } return r } func recResult(rec mbRecording) Result { r := Result{ Provider: "musicbrainz", ProviderID: "recording:" + rec.ID, Title: rec.Title, Subtitle: mbArtists(rec.ArtistCredit), Year: yearOf(rec.FirstReleaseDate), ExternalURL: "https://musicbrainz.org/recording/" + rec.ID, Extra: map[string]string{}, } // Prefer an album release for the artwork. for i, rel := range rec.Releases { if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" { r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500" r.Extra["album"] = rel.Title r.Hint = "From " + rel.Title if rel.ReleaseGroup.PrimaryType == "Album" { break } } } if rec.Length > 0 { s := rec.Length / 1000 r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60) } return r }